From 027ef997ee611d28ef9f9c769b6f27d27329a239 Mon Sep 17 00:00:00 2001 From: Augusto Xavier Date: Thu, 11 Aug 2022 15:19:21 -0300 Subject: [PATCH 0001/1075] Update docs for `conf.hosts` to be more explicit about the main domain --- guides/source/configuring.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/guides/source/configuring.md b/guides/source/configuring.md index e04555e2f99ab..b12640ec1e53f 100644 --- a/guides/source/configuring.md +++ b/guides/source/configuring.md @@ -611,11 +611,10 @@ The provided regexp will be wrapped with both anchors (`\A` and `\z`) so it must match the entire hostname. `/product.com/`, for example, once anchored, would fail to match `www.product.com`. -A special case is supported that allows you to permit all sub-domains: +A special case is supported that allows you to permit the domain and all sub-domains: ```ruby -# Allow requests from subdomains like `www.product.com` and -# `beta1.product.com`. +# Allow requests the domain itself `product.com` and from subdomains like `www.product.com` and `beta1.product.com`. Rails.application.config.hosts << ".product.com" ``` From 592650c5cb4830326033c1d79213e5c985707e9a Mon Sep 17 00:00:00 2001 From: Augusto Xavier Date: Tue, 16 Aug 2022 10:37:20 -0300 Subject: [PATCH 0002/1075] Update guides/source/configuring.md Co-authored-by: Mark Schneider --- guides/source/configuring.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/guides/source/configuring.md b/guides/source/configuring.md index b12640ec1e53f..1cd73dccb9d04 100644 --- a/guides/source/configuring.md +++ b/guides/source/configuring.md @@ -614,7 +614,7 @@ would fail to match `www.product.com`. A special case is supported that allows you to permit the domain and all sub-domains: ```ruby -# Allow requests the domain itself `product.com` and from subdomains like `www.product.com` and `beta1.product.com`. +# Allow requests from the domain itself `product.com` and subdomains like `www.product.com` and `beta1.product.com`. Rails.application.config.hosts << ".product.com" ``` From cdab53808cab78a462129679562b746bc87e107f Mon Sep 17 00:00:00 2001 From: Bernardo Barreto Date: Fri, 30 Aug 2024 11:59:51 -0300 Subject: [PATCH 0003/1075] Fix migration log message for down operations --- activerecord/lib/active_record/migration.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/activerecord/lib/active_record/migration.rb b/activerecord/lib/active_record/migration.rb index 47901fb12acb9..b660392051733 100644 --- a/activerecord/lib/active_record/migration.rb +++ b/activerecord/lib/active_record/migration.rb @@ -1536,7 +1536,8 @@ def execute_migration_in_transaction(migration) return if down? && !migrated.include?(migration.version.to_i) return if up? && migrated.include?(migration.version.to_i) - Base.logger.info "Migrating to #{migration.name} (#{migration.version})" if Base.logger + message = up? ? "Migrating to" : "Reverting" + Base.logger.info "#{message} #{migration.name} (#{migration.version})" if Base.logger ddl_transaction(migration) do migration.migrate(@direction) From ccb7464da25975a54b2d103cd31b34eb1c033810 Mon Sep 17 00:00:00 2001 From: Adrien Siami Date: Thu, 13 Jun 2024 21:28:18 +0200 Subject: [PATCH 0004/1075] Include the actual ActiveJob locale when serializing rather than I18n.locale --- activejob/lib/active_job/core.rb | 2 +- .../test/cases/job_serialization_test.rb | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/activejob/lib/active_job/core.rb b/activejob/lib/active_job/core.rb index 879505e48d4bb..1e2a0fb6ba563 100644 --- a/activejob/lib/active_job/core.rb +++ b/activejob/lib/active_job/core.rb @@ -114,7 +114,7 @@ def serialize "arguments" => serialize_arguments_if_needed(arguments), "executions" => executions, "exception_executions" => exception_executions, - "locale" => I18n.locale.to_s, + "locale" => locale || I18n.locale.to_s, "timezone" => timezone, "enqueued_at" => Time.now.utc.iso8601(9), "scheduled_at" => scheduled_at ? scheduled_at.utc.iso8601(9) : nil, diff --git a/activejob/test/cases/job_serialization_test.rb b/activejob/test/cases/job_serialization_test.rb index 916de4d426a81..38192691cc6c0 100644 --- a/activejob/test/cases/job_serialization_test.rb +++ b/activejob/test/cases/job_serialization_test.rb @@ -21,6 +21,25 @@ class JobSerializationTest < ActiveSupport::TestCase assert_equal "en", HelloJob.new.serialize["locale"] end + test "a deserialized job keeps its locale even if I18n.locale changes" do + old_locales = I18n.available_locales + begin + I18n.available_locales = [:en, :es] + I18n.locale = :es + payload = HelloJob.new.serialize + assert_equal "es", payload["locale"] + + I18n.locale = :en + + new_job = HelloJob.new + new_job.deserialize(payload) + + assert_equal "es", new_job.serialize["locale"] + ensure + I18n.available_locales = old_locales + end + end + test "serialize and deserialize are symmetric" do # Ensure `enqueued_at` does not change between serializations freeze_time From eac0eec9a02972b78571c72cd4e6cf382447f6e6 Mon Sep 17 00:00:00 2001 From: Ben Koshy Date: Sat, 28 Dec 2024 13:56:45 +1100 Subject: [PATCH 0005/1075] docs: rails debug guide - add detail on verbose logs [ci-skip] Why this PR? * The docs make reference to enabling verbose queries, but do not explicitly state how this can be done. * We have also reviewed other parts of the guide, and have tidied it up. --- guides/source/7_1_release_notes.md | 2 +- guides/source/debugging_rails_applications.md | 80 +++++++++---------- 2 files changed, 40 insertions(+), 42 deletions(-) diff --git a/guides/source/7_1_release_notes.md b/guides/source/7_1_release_notes.md index 0ba0baadd6b53..9c0a181b4ff42 100644 --- a/guides/source/7_1_release_notes.md +++ b/guides/source/7_1_release_notes.md @@ -866,7 +866,7 @@ Please refer to the [Changelog][active-job] for detailed changes. * Add `after_discard` method to `ActiveJob::Base` to run a callback when a job is about to be discarded. -* Add support for logging background job enqueue callers. +* Add support for logging background job enqueue callers via `config.active_job.verbose_enqueue_logs`. Action Text ---------- diff --git a/guides/source/debugging_rails_applications.md b/guides/source/debugging_rails_applications.md index c7a3a583f0c58..4e42d0e40886c 100644 --- a/guides/source/debugging_rails_applications.md +++ b/guides/source/debugging_rails_applications.md @@ -107,22 +107,14 @@ It can also be useful to save information to log files at runtime. Rails maintai ### What is the Logger? -Rails makes use of the `ActiveSupport::Logger` class to write log information. Other loggers, such as `Log4r`, may also be substituted. - -You can specify an alternative logger in `config/application.rb` or any other environment file, for example: +Rails makes use of the `ActiveSupport::Logger` class to write log information. Other loggers, such as `Log4r`, may be substituted: ```ruby +# config/environments/production.rb config.logger = Logger.new(STDOUT) config.logger = Log4r::Logger.new("Application Log") ``` -Or in the `Initializer` section, add _any_ of the following - -```ruby -Rails.logger = Logger.new(STDOUT) -Rails.logger = Log4r::Logger.new("Application Log") -``` - TIP: By default, each log is created under `Rails.root/log/` and the log file is named after the environment in which the application is running. ### Log Levels @@ -134,11 +126,11 @@ method. The available log levels are: `:debug`, `:info`, `:warn`, `:error`, `:fatal`, and `:unknown`, corresponding to the log level numbers from 0 up to 5, -respectively. To change the default log level, use +respectively. To change the default log level: ```ruby -config.log_level = :warn # In any environment initializer, or -Rails.logger.level = 0 # at any time +# config/environments/production.rb +config.log_level = :warn ``` This is useful when you want to log under development or staging without flooding your production log with unnecessary information. @@ -217,7 +209,7 @@ irb(main):001:0> Article.pamplemousse => # ``` -After running `ActiveRecord.verbose_query_logs = true` in the `bin/rails console` session to enable verbose query logs and running the method again, it becomes obvious what single line of code is generating all these discrete database calls: +After enabling `verbose_query_logs` we can see additional information for each query: ```irb irb(main):003:0> Article.pamplemousse @@ -232,9 +224,11 @@ irb(main):003:0> Article.pamplemousse => # ``` -Below each database statement you can see arrows pointing to the specific source filename (and line number) of the method that resulted in a database call. This can help you identify and address performance problems caused by N+1 queries: single database queries that generates multiple additional queries. +Below each database statement you can see arrows pointing to the specific source filename (and line number) of the method that resulted in a database call e.g. `↳ app/models/article.rb:5`. + +This can help you identify and address performance problems caused by N+1 queries: i.e. single database queries that generate multiple additional queries. -Verbose query logs are enabled by default in the development environment logs after Rails 5.2. +Verbose query logs are [enabled by default](configuring.html#config-active-record-verbose-query-logs) in the development environment logs. WARNING: We recommend against using this setting in production environments. It relies on Ruby's `Kernel#caller` method which tends to allocate a lot of memory in order to generate stacktraces of method calls. Use query log tags (see below) instead. @@ -242,13 +236,19 @@ WARNING: We recommend against using this setting in production environments. It Similar to the "Verbose Query Logs" above, allows to print source locations of methods that enqueue background jobs. -It is enabled by default in development. To enable in other environments, add in `application.rb` or any environment initializer: +Verbose enqueue logs are [enabled by default](/7_1_release_notes.html#active-job-notable-changes) in the development environment logs. -```rb +```ruby +# config/environments/development.rb config.active_job.verbose_enqueue_logs = true ``` -As verbose query logs, it is not recommended for use in production environments. +```irb +# bin/rails console +ActiveJob.verbose_enqueue_logs = true +``` + +WARNING: We recommend against using this setting in production environments. SQL Query Comments ------------------ @@ -258,9 +258,7 @@ trace troublesome queries back to the area of the application that generated the logging slow queries (e.g. [MySQL](https://dev.mysql.com/doc/refman/en/slow-query-log.html), [PostgreSQL](https://www.postgresql.org/docs/current/runtime-config-logging.html#GUC-LOG-MIN-DURATION-STATEMENT)), viewing currently running queries, or for end-to-end tracing tools. -To enable, add in `application.rb` or any environment initializer: - -```rb +```ruby config.active_record.query_log_tags_enabled = true ``` @@ -349,7 +347,7 @@ By default, a debugging session will start after the `debug` library is required To enter the debugging session, you can use `binding.break` and its aliases: `binding.b` and `debugger`. The following examples will use `debugger`: -```rb +```ruby class PostsController < ApplicationController before_action :set_post, only: %i[ show edit update destroy ] @@ -364,7 +362,7 @@ end Once your app evaluates the debugging statement, it'll enter the debugging session: -```rb +```ruby Processing by PostsController#index as HTML [2, 11] in ~/projects/rails-guide-example/app/controllers/posts_controller.rb 2| before_action :set_post, only: %i[ show edit update destroy ] @@ -389,7 +387,7 @@ You can exit the debugging session at any time and continue your application exe After entering the debugging session, you can type in Ruby code as if you are in a Rails console or IRB. -```rb +```ruby (rdbg) @posts # ruby [] (rdbg) self @@ -399,7 +397,7 @@ After entering the debugging session, you can type in Ruby code as if you are in You can also use the `p` or `pp` command to evaluate Ruby expressions, which is useful when a variable name conflicts with a debugger command. -```rb +```ruby (rdbg) p headers # command => {"X-Frame-Options"=>"SAMEORIGIN", "X-XSS-Protection"=>"1; mode=block", "X-Content-Type-Options"=>"nosniff", "X-Download-Options"=>"noopen", "X-Permitted-Cross-Domain-Policies"=>"none", "Referrer-Policy"=>"strict-origin-when-cross-origin"} (rdbg) pp headers # command @@ -422,7 +420,7 @@ Besides direct evaluation, the debugger also helps you collect a rich amount of `info` provides an overview of the values of local and instance variables that are visible from the current frame. -```rb +```ruby (rdbg) info # command %self = # @_action_has_layout = true @@ -442,7 +440,7 @@ Besides direct evaluation, the debugger also helps you collect a rich amount of When used without any options, `backtrace` lists all the frames on the stack: -```rb +```ruby =>#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-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-8.1.0.alpha/lib/abstract_controller/base.rb:214 @@ -482,7 +480,7 @@ It is also possible to use these options together: `backtrace [num] /pattern/`. - Class variables - Methods & their sources -```rb +```ruby ActiveSupport::Configurable#methods: config AbstractController::Base#methods: action_methods action_name action_name= available_action? controller_path inspect @@ -531,7 +529,7 @@ And to remove them, you can use: **Set a breakpoint on a specified line number - e.g. `b 28`** -```rb +```ruby [20, 29] in ~/projects/rails-guide-example/app/controllers/posts_controller.rb 20| end 21| @@ -550,7 +548,7 @@ And to remove them, you can use: #0 BP - Line /Users/st0012/projects/rails-guide-example/app/controllers/posts_controller.rb:28 (line) ``` -```rb +```ruby (rdbg) c # continue command [23, 32] in ~/projects/rails-guide-example/app/controllers/posts_controller.rb 23| def create @@ -572,7 +570,7 @@ Stop by #0 BP - Line /Users/st0012/projects/rails-guide-example/app/controller Set a breakpoint on a given method call - e.g. `b @post.save`. -```rb +```ruby [20, 29] in ~/projects/rails-guide-example/app/controllers/posts_controller.rb 20| end 21| @@ -592,7 +590,7 @@ Set a breakpoint on a given method call - e.g. `b @post.save`. ``` -```rb +```ruby (rdbg) c # continue command [39, 48] in ~/.rbenv/versions/3.0.1/lib/ruby/gems/3.0.0/gems/activerecord-7.0.0.alpha2/lib/active_record/suppressor.rb 39| SuppressorRegistry.suppressed[name] = previous_state @@ -616,7 +614,7 @@ Stop by #0 BP - Method @post.save at /Users/st0012/.rbenv/versions/3.0.1/lib/r Stop when an exception is raised - e.g. `catch ActiveRecord::RecordInvalid`. -```rb +```ruby [20, 29] in ~/projects/rails-guide-example/app/controllers/posts_controller.rb 20| end 21| @@ -635,7 +633,7 @@ Stop when an exception is raised - e.g. `catch ActiveRecord::RecordInvalid`. #1 BP - Catch "ActiveRecord::RecordInvalid" ``` -```rb +```ruby (rdbg) c # continue command [75, 84] in ~/.rbenv/versions/3.0.1/lib/ruby/gems/3.0.0/gems/activerecord-7.0.0.alpha2/lib/active_record/validations.rb 75| def default_validation_context @@ -659,7 +657,7 @@ Stop by #1 BP - Catch "ActiveRecord::RecordInvalid" Stop when the instance variable is changed - e.g. `watch @_response_body`. -```rb +```ruby [20, 29] in ~/projects/rails-guide-example/app/controllers/posts_controller.rb 20| end 21| @@ -678,7 +676,7 @@ Stop when the instance variable is changed - e.g. `watch @_response_body`. #0 BP - Watch # @_response_body = ``` -```rb +```ruby (rdbg) c # continue command [173, 182] in ~/.rbenv/versions/3.0.1/lib/ruby/gems/3.0.0/gems/actionpack-7.0.0.alpha2/lib/action_controller/metal.rb 173| body = [body] unless body.nil? || body.respond_to?(:each) @@ -714,7 +712,7 @@ In addition to different types of breakpoints, you can also specify options to a Please also note that the first 3 options: `do:`, `pre:` and `if:` are also available for the debug statements we mentioned earlier. For example: -```rb +```ruby [2, 11] in ~/projects/rails-guide-example/app/controllers/posts_controller.rb 2| before_action :set_post, only: %i[ show edit update destroy ] 3| @@ -748,7 +746,7 @@ Please also note that the first 3 options: `do:`, `pre:` and `if:` are also avai With those options, you can script your debugging workflow in one line like: -```rb +```ruby def create debugger(do: "catch ActiveRecord::RecordInvalid do: bt 10") # ... @@ -757,7 +755,7 @@ end And then the debugger will run the scripted command and insert the catch breakpoint -```rb +```ruby (rdbg:binding.break) catch ActiveRecord::RecordInvalid do: bt 10 #0 BP - Catch "ActiveRecord::RecordInvalid" [75, 84] in ~/.rbenv/versions/3.0.1/lib/ruby/gems/3.0.0/gems/activerecord-7.0.0.alpha2/lib/active_record/validations.rb @@ -778,7 +776,7 @@ And then the debugger will run the scripted command and insert the catch breakpo Once the catch breakpoint is triggered, it'll print the stack frames -```rb +```ruby Stop by #0 BP - Catch "ActiveRecord::RecordInvalid" (rdbg:catch) bt 10 From 6904998d19aa7ca5d0dec8cca983c870a1a23190 Mon Sep 17 00:00:00 2001 From: Petrik Date: Tue, 21 Jan 2025 15:11:11 +0100 Subject: [PATCH 0006/1075] Enable comments formatting on bash examples in guides The ConsoleLexer allows marking lines starting with `#` as comments instead of a prompt (which is the default). The improves the formatting of bash examples. https://www.rubydoc.info/gems/rouge/Rouge/Lexers/ConsoleLexer --- guides/rails_guides/markdown/renderer.rb | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/guides/rails_guides/markdown/renderer.rb b/guides/rails_guides/markdown/renderer.rb index 8e419113a4c7b..512df2c75e8ac 100644 --- a/guides/rails_guides/markdown/renderer.rb +++ b/guides/rails_guides/markdown/renderer.rb @@ -32,7 +32,7 @@ class Renderer < Redcarpet::Render::HTML # :nodoc: def block_code(code, language) language, lines = split_language_highlights(language) formatter = Rouge::Formatters::HTMLLineHighlighter.new(Rouge::Formatters::HTML.new, highlight_lines: lines) - lexer = ::Rouge::Lexer.find_fancy(lexer_language(language)) + lexer = ::Rouge::Lexer.find_fancy(lexer_language_with_options(language)) formatted_code = formatter.format(lexer.lex(code)) <<~HTML
@@ -97,6 +97,16 @@ def lexer_language(code_type) end end + def lexer_language_with_options(code_type) + language = lexer_language(code_type) + case language + when "console" + "#{language}?comments=true" + else + language + end + end + def clipboard_content(code, language) # Remove prompt and results of commands. prompt_regexp = From c0aa9c0e4c7f56509366bb124b03f97ad8a9d223 Mon Sep 17 00:00:00 2001 From: madogiwa0124 Date: Mon, 13 Jan 2025 01:13:06 +0000 Subject: [PATCH 0007/1075] Support hash-source in Content Security Policy The CSP hash-source is not enclosed in ' and is ignored by the browser. According to the official W3C definition of a hash-source, it must be enclosed in '. > hash-source = "'" hash-algorithm "-" base64-value "'" > hash-algorithm = "sha256" / "sha384" / "sha512" > https://www.w3.org/TR/CSP3/#grammardef-hash-source --- .../http/content_security_policy.rb | 14 +++++++++++++- .../test/dispatch/content_security_policy_test.rb | 5 +++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/actionpack/lib/action_dispatch/http/content_security_policy.rb b/actionpack/lib/action_dispatch/http/content_security_policy.rb index 9194765a2e27e..2c21ff9a4120f 100644 --- a/actionpack/lib/action_dispatch/http/content_security_policy.rb +++ b/actionpack/lib/action_dispatch/http/content_security_policy.rb @@ -171,6 +171,8 @@ def generate_content_security_policy_nonce worker_src: "worker-src" }.freeze + HASH_SOURCE_ALGORITHM_PREFIXES = ["sha256-", "sha384-", "sha512-"].freeze + DEFAULT_NONCE_DIRECTIVES = %w[script-src style-src].freeze private_constant :MAPPINGS, :DIRECTIVES, :DEFAULT_NONCE_DIRECTIVES @@ -305,7 +307,13 @@ def apply_mappings(sources) case source when Symbol apply_mapping(source) - when String, Proc + when String + if hash_source?(source) + "'#{source}'" + else + source + end + when Proc source else raise ArgumentError, "Invalid content security policy source: #{source.inspect}" @@ -374,5 +382,9 @@ def resolve_source(source, context) def nonce_directive?(directive, nonce_directives) nonce_directives.include?(directive) end + + def hash_source?(source) + source.start_with?(*HASH_SOURCE_ALGORITHM_PREFIXES) + end end end diff --git a/actionpack/test/dispatch/content_security_policy_test.rb b/actionpack/test/dispatch/content_security_policy_test.rb index b0b6067cc71c0..2bd90e22f6617 100644 --- a/actionpack/test/dispatch/content_security_policy_test.rb +++ b/actionpack/test/dispatch/content_security_policy_test.rb @@ -269,6 +269,11 @@ def test_other_directives assert_no_match %r{upgrade-insecure-requests}, @policy.build end + def test_hash_sources + @policy.script_src "sha256-hash", "sha384-hash", "sha512-hash", "invalid-hash", "sha256hash" + assert_equal "script-src 'sha256-hash' 'sha384-hash' 'sha512-hash' invalid-hash sha256hash", @policy.build + end + def test_multiple_sources @policy.script_src :self, :https assert_equal "script-src 'self' https:", @policy.build From 044d2eac9fe3200e4b43f5ae3f03423e8ab9317b Mon Sep 17 00:00:00 2001 From: fatkodima Date: Fri, 24 Jan 2025 13:22:53 +0200 Subject: [PATCH 0008/1075] Fix `Enumerable#sole` for infinite collections --- .../lib/active_support/core_ext/enumerable.rb | 20 +++++++++++++++---- .../lib/active_support/core_ext/range.rb | 1 + .../lib/active_support/core_ext/range/sole.rb | 17 ++++++++++++++++ .../test/core_ext/enumerable_test.rb | 1 + activesupport/test/core_ext/range_ext_test.rb | 12 +++++++++++ 5 files changed, 47 insertions(+), 4 deletions(-) create mode 100644 activesupport/lib/active_support/core_ext/range/sole.rb diff --git a/activesupport/lib/active_support/core_ext/enumerable.rb b/activesupport/lib/active_support/core_ext/enumerable.rb index 7ae0dc6e77b64..2ab2519ba03c2 100644 --- a/activesupport/lib/active_support/core_ext/enumerable.rb +++ b/activesupport/lib/active_support/core_ext/enumerable.rb @@ -209,10 +209,22 @@ def in_order_of(key, series, filter: true) # Set.new.sole # => Enumerable::SoleItemExpectedError: no item found # { a: 1, b: 2 }.sole # => Enumerable::SoleItemExpectedError: multiple items found def sole - case count - when 1 then return first # rubocop:disable Style/RedundantReturn - when 0 then raise ActiveSupport::EnumerableCoreExt::SoleItemExpectedError, "no item found" - when 2.. then raise ActiveSupport::EnumerableCoreExt::SoleItemExpectedError, "multiple items found" + result = nil + found = false + + each do |element| + if found + raise SoleItemExpectedError, "multiple items found" + end + + result = element + found = true + end + + if found + result + else + raise SoleItemExpectedError, "no item found" end end end diff --git a/activesupport/lib/active_support/core_ext/range.rb b/activesupport/lib/active_support/core_ext/range.rb index 6643255cc0572..a2e678c2cb001 100644 --- a/activesupport/lib/active_support/core_ext/range.rb +++ b/activesupport/lib/active_support/core_ext/range.rb @@ -3,3 +3,4 @@ require "active_support/core_ext/range/conversions" require "active_support/core_ext/range/compare_range" require "active_support/core_ext/range/overlap" +require "active_support/core_ext/range/sole" diff --git a/activesupport/lib/active_support/core_ext/range/sole.rb b/activesupport/lib/active_support/core_ext/range/sole.rb new file mode 100644 index 0000000000000..6d2f9c62c8ee1 --- /dev/null +++ b/activesupport/lib/active_support/core_ext/range/sole.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class Range + # Returns the sole item in the range. If there are no items, or more + # than one item, raises Enumerable::SoleItemExpectedError. + # + # (1..1).sole # => 1 + # (2..1).sole # => Enumerable::SoleItemExpectedError: no item found + # (..1).sole # => Enumerable::SoleItemExpectedError: infinite range cannot represent a sole item + def sole + if self.begin.nil? || self.end.nil? + raise ActiveSupport::EnumerableCoreExt::SoleItemExpectedError, "infinite range '#{inspect}' cannot represent a sole item" + end + + super + end +end diff --git a/activesupport/test/core_ext/enumerable_test.rb b/activesupport/test/core_ext/enumerable_test.rb index c7f12363e2bcc..0817fa575d33e 100644 --- a/activesupport/test/core_ext/enumerable_test.rb +++ b/activesupport/test/core_ext/enumerable_test.rb @@ -398,6 +398,7 @@ def test_sole assert_equal 1, GenericEnumerable.new([1]).sole assert_raise(expected_raise) { GenericEnumerable.new([1, 2]).sole } assert_raise(expected_raise) { GenericEnumerable.new([1, nil]).sole } + assert_raise(expected_raise) { GenericEnumerable.new(1..).sole } end def test_doesnt_bust_constant_cache diff --git a/activesupport/test/core_ext/range_ext_test.rb b/activesupport/test/core_ext/range_ext_test.rb index 718deca2735a1..2e5994e6bf7cf 100644 --- a/activesupport/test/core_ext/range_ext_test.rb +++ b/activesupport/test/core_ext/range_ext_test.rb @@ -287,4 +287,16 @@ def test_date_time_with_step datetime = DateTime.now assert(((datetime - 1.hour)..datetime).step(1) { }) end + + def test_sole + assert_equal 1, (1..1).sole + + assert_raises(Enumerable::SoleItemExpectedError, match: "no item found") do + (2..1).sole + end + + assert_raises(Enumerable::SoleItemExpectedError, match: "infinite range '..1' cannot represent a sole item") do + (..1).sole + end + end end From 8117a87565af371ce3d59c1bcf5f66a9f87cf30b Mon Sep 17 00:00:00 2001 From: Patricio Mac Adden Date: Tue, 21 Jan 2025 11:30:55 -0300 Subject: [PATCH 0009/1075] scaffold: generate valid html when the model has_many_attached --- .../generators/erb/scaffold/templates/partial.html.erb.tt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/railties/lib/rails/generators/erb/scaffold/templates/partial.html.erb.tt b/railties/lib/rails/generators/erb/scaffold/templates/partial.html.erb.tt index fb78ea1eabc4f..86b4bc6c537e9 100644 --- a/railties/lib/rails/generators/erb/scaffold/templates/partial.html.erb.tt +++ b/railties/lib/rails/generators/erb/scaffold/templates/partial.html.erb.tt @@ -1,6 +1,6 @@
<% attributes.reject(&:password_digest?).each do |attribute| -%> -

+

<%= attribute.human_name %>: <% if attribute.attachment? -%> <%%= link_to <%= singular_name %>.<%= attribute.column_name %>.filename, <%= singular_name %>.<%= attribute.column_name %> if <%= singular_name %>.<%= attribute.column_name %>.attached? %> @@ -11,7 +11,7 @@ <% else -%> <%%= <%= singular_name %>.<%= attribute.column_name %> %> <% end -%> -

+
<% end -%>
From 706677571d8a54763c5c78b81247cc027ff969f8 Mon Sep 17 00:00:00 2001 From: Hartley McGuire Date: Tue, 25 Feb 2025 20:48:08 -0500 Subject: [PATCH 0010/1075] Only create one config per AV::Base Currently, AV::Base's config is always initialized to a blank InheritableOptions, and then later config is overridden with the given controller's config if it responds to config (which it will most of the time since AbstractController::Base has a config). This commit moves the blank config instantiation into the ControllerHelper so that only a single InheritableOptions is ever instantiated for an AV::Base. --- actionview/lib/action_view/base.rb | 2 -- actionview/lib/action_view/helpers/controller_helper.rb | 8 ++++++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/actionview/lib/action_view/base.rb b/actionview/lib/action_view/base.rb index 59ae9bd8d16a4..4fd766d4ab62f 100644 --- a/actionview/lib/action_view/base.rb +++ b/actionview/lib/action_view/base.rb @@ -242,8 +242,6 @@ def self.with_context(context, assigns = {}, controller = nil) # :startdoc: def initialize(lookup_context, assigns, controller) # :nodoc: - @_config = ActiveSupport::InheritableOptions.new - @lookup_context = lookup_context @view_renderer = ActionView::Renderer.new @lookup_context diff --git a/actionview/lib/action_view/helpers/controller_helper.rb b/actionview/lib/action_view/helpers/controller_helper.rb index 38aa015a911da..d6e45076df91a 100644 --- a/actionview/lib/action_view/helpers/controller_helper.rb +++ b/actionview/lib/action_view/helpers/controller_helper.rb @@ -20,11 +20,15 @@ module ControllerHelper # :nodoc: def assign_controller(controller) if @_controller = controller @_request = controller.request if controller.respond_to?(:request) - @_config = controller.config.inheritable_copy if controller.respond_to?(:config) + if controller.respond_to?(:config) + @_config = controller.config.inheritable_copy + else + @_config = ActiveSupport::InheritableOptions.new + end @_default_form_builder = controller.default_form_builder if controller.respond_to?(:default_form_builder) else @_request ||= nil - @_config ||= nil + @_config = ActiveSupport::InheritableOptions.new @_default_form_builder ||= nil end end From f4408dd291da29c53d49370dabeb213a05bb9c59 Mon Sep 17 00:00:00 2001 From: Edouard CHIN Date: Fri, 28 Feb 2025 04:06:38 +0100 Subject: [PATCH 0011/1075] Modify the Test Runner to allow running test at root: - ### Problem I'd like to be able to do `bin/rails my_test.rb`. But I can't, as `my_test.rb` is not considered a path by the runner, so Rails ends up running the entire test suite. This is also confusing, when I run `bin/rails test unexisting.rb` I don't get a load error, and the whole suite runs. ### Solution Don't make the assumptions about the `/` character to consider the arg as a path. Instead, skip over any arg that is preceded by a flag, and consider all others to be a path. ### Caveat (Not really one, since you could never do that.) Running a file named `--file` or `-file` is not possible. But this isn't anything new, Minitest will trip over when running the OptionParser and see that this "flag" isn't defined. ### Bonus point You can now run a test that has a slash in its name. `bin/rails test -n foo\/bar` --- railties/lib/rails/test_unit/runner.rb | 16 +++++-- railties/test/application/test_runner_test.rb | 48 +++++++++++++++++++ 2 files changed, 59 insertions(+), 5 deletions(-) diff --git a/railties/lib/rails/test_unit/runner.rb b/railties/lib/rails/test_unit/runner.rb index 7ae989f10d038..27e6c3a710251 100644 --- a/railties/lib/rails/test_unit/runner.rb +++ b/railties/lib/rails/test_unit/runner.rb @@ -91,12 +91,22 @@ def compose_filter(runnable, filter) private def extract_filters(argv) + previous_arg_was_a_flag = false # Extract absolute and relative paths but skip -n /.*/ regexp filters. argv.filter_map do |path| - next unless path_argument?(path) + current_arg_is_a_flag = /^-{1,2}[a-zA-Z0-9\-_.]+=?.*\Z/.match?(path) + + if previous_arg_was_a_flag && !current_arg_is_a_flag + # Handle the case where a flag is followed by another flag (e.g. --fail-fast --seed ...) + previous_arg_was_a_flag = false + next + end path = path.tr("\\", "/") case + when current_arg_is_a_flag + previous_arg_was_a_flag = true unless path.include?("=") # Handle the case when "--foo=bar" is used. + next when /(:\d+(-\d+)?)+$/.match?(path) file, *lines = path.split(":") filters << [ file, lines ] @@ -122,10 +132,6 @@ def regexp_filter?(arg) arg.start_with?("/") && arg.end_with?("/") end - def path_argument?(arg) - PATH_ARGUMENT_PATTERN.match?(arg) - end - def list_tests(patterns) tests = Rake::FileList[patterns.any? ? patterns : default_test_glob] tests.exclude(default_test_exclude_glob) if patterns.empty? diff --git a/railties/test/application/test_runner_test.rb b/railties/test/application/test_runner_test.rb index cf046ab4480ad..85b8a71c4b5fb 100644 --- a/railties/test/application/test_runner_test.rb +++ b/railties/test/application/test_runner_test.rb @@ -222,6 +222,54 @@ def test_sanae end end + def test_run_test_at_root + app_file "my_test.rb", <<-RUBY + require "test_helper" + + class MyTest < ActiveSupport::TestCase + def test_rikka + puts 'Rikka' + end + end + RUBY + + run_test_command("my_test.rb").tap do |output| + assert_match "Rikka", output + end + end + + def test_run_test_having_a_slash_in_its_name + app_file "my_test.rb", <<-RUBY + require "test_helper" + + class MyTest < ActiveSupport::TestCase + test "foo/foo" do + puts 'Rikka' + end + end + RUBY + + run_test_command("my_test.rb -n foo\/foo").tap do |output| + assert_match "Rikka", output + end + end + + def test_run_test_with_flags_unordered + app_file "my_test.rb", <<-RUBY + require "test_helper" + + class MyTest < ActiveSupport::TestCase + test "foo/foo" do + puts 'Rikka' + end + end + RUBY + + run_test_command("--seed 344 my_test.rb --fail-fast -n foo\/foo").tap do |output| + assert_match "Rikka", output + end + end + def test_run_matched_test app_file "test/unit/chu_2_koi_test.rb", <<-RUBY require "test_helper" From 43efe464a52e9f1c9259e6a90c4da9a9163bd762 Mon Sep 17 00:00:00 2001 From: Yedhin Kizhakkethara Date: Fri, 28 Feb 2025 11:35:13 +0530 Subject: [PATCH 0012/1075] Document pluralize_table_names limitation with Rails generators/installers Installers for libraries like say Active Storage and Action Text create plural table names regardless of pluralize_table_names setting. This adds documentation to warn users about this limitation and necessary manual steps. Fixes #54529 --- guides/source/configuring.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/guides/source/configuring.md b/guides/source/configuring.md index 37ad8fab8af13..d2a5f4af4f6c4 100644 --- a/guides/source/configuring.md +++ b/guides/source/configuring.md @@ -1072,6 +1072,13 @@ Lets you set an array of names of environments where destructive actions should Specifies whether Rails will look for singular or plural table names in the database. If set to `true` (the default), then the Customer class will use the `customers` table. If set to `false`, then the Customer class will use the `customer` table. +WARNING: Some Rails generators and installers (notably `active_storage:install` +and `action_text:install`) create tables with plural names regardless of this +setting. If you set `pluralize_table_names` to `false`, you will need to +manually rename those tables after installation to maintain consistency. +This limitation exists because these installers use fixed table names +in their migrations for compatibility reasons. + #### `config.active_record.default_timezone` Determines whether to use `Time.local` (if set to `:local`) or `Time.utc` (if set to `:utc`) when pulling dates and times from the database. The default is `:utc`. From 6d2f01223ab47579a7f5f8138d50eb14d8b0a3ce Mon Sep 17 00:00:00 2001 From: Ted Martin <13721164+etmartinkazoo@users.noreply.github.com> Date: Sat, 1 Mar 2025 11:32:50 -0500 Subject: [PATCH 0013/1075] [ci-skip] Fix Horizontal Scroll on Mobile: On mobile devices, when reading the Rails Guides I noticed horizontal scroll. This can be fixed with a few small CSS tweaks around absolute width in .code, white-space: normal, overflow: scroll and white-space: pre respectively. Co-authored-by: Juan Vasquez --- guides/assets/stylesrc/components/_code-container.scss | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/guides/assets/stylesrc/components/_code-container.scss b/guides/assets/stylesrc/components/_code-container.scss index e72082707c554..ce783209e2b94 100644 --- a/guides/assets/stylesrc/components/_code-container.scss +++ b/guides/assets/stylesrc/components/_code-container.scss @@ -44,10 +44,13 @@ dl dd .interstitial { padding-left: 1em !important; // remove if icon is restored direction: ltr !important; text-align: left !important; + width: 100%; + white-space: normal; pre { margin: 0; - overflow: visible; // allows for the blue highlight to be seen + overflow: scroll; + white-space: pre; } button.clipboard-button { From 1600a0cf9f284d4e2168158a9af0bc2b95a21fd4 Mon Sep 17 00:00:00 2001 From: fatkodima Date: Thu, 6 Mar 2025 19:52:20 +0200 Subject: [PATCH 0014/1075] Expose ability to prepend query log tags via application configuration --- activerecord/lib/active_record/query_logs.rb | 2 +- activerecord/lib/active_record/railtie.rb | 6 ++++++ guides/source/configuring.md | 11 +++++++++++ railties/test/application/query_logs_test.rb | 19 ++++++++++++++++--- 4 files changed, 34 insertions(+), 4 deletions(-) diff --git a/activerecord/lib/active_record/query_logs.rb b/activerecord/lib/active_record/query_logs.rb index 0d4361e8ffb83..2b06340eeeb8c 100644 --- a/activerecord/lib/active_record/query_logs.rb +++ b/activerecord/lib/active_record/query_logs.rb @@ -65,7 +65,7 @@ module ActiveRecord # # Tag comments can be prepended to the query: # - # ActiveRecord::QueryLogs.prepend_comment = true + # config.active_record.query_log_tags_prepend_comment = true # # For applications where the content will not change during the lifetime of # the request or job execution, the tags can be cached for reuse in every query: diff --git a/activerecord/lib/active_record/railtie.rb b/activerecord/lib/active_record/railtie.rb index 3b99a066fe149..c4b37e38ce03a 100644 --- a/activerecord/lib/active_record/railtie.rb +++ b/activerecord/lib/active_record/railtie.rb @@ -35,6 +35,7 @@ class Railtie < Rails::Railtie # :nodoc: config.active_record.query_log_tags = [ :application ] config.active_record.query_log_tags_format = :legacy config.active_record.cache_query_log_tags = false + config.active_record.query_log_tags_prepend_comment = 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 @@ -229,6 +230,7 @@ class Railtie < Rails::Railtie # :nodoc: :query_log_tags, :query_log_tags_format, :cache_query_log_tags, + :query_log_tags_prepend_comment, :sqlite3_adapter_strict_strings_by_default, :check_schema_cache_dump_version, :use_schema_cache_dump, @@ -387,6 +389,10 @@ class Railtie < Rails::Railtie # :nodoc: if app.config.active_record.cache_query_log_tags ActiveRecord::QueryLogs.cache_query_log_tags = true end + + if app.config.active_record.query_log_tags_prepend_comment + ActiveRecord::QueryLogs.prepend_comment = true + end end end end diff --git a/guides/source/configuring.md b/guides/source/configuring.md index dd3ce6f2fc579..573b9778f9814 100644 --- a/guides/source/configuring.md +++ b/guides/source/configuring.md @@ -1532,6 +1532,17 @@ that have a large number of queries, caching query log tags can provide a performance benefit when the context does not change during the lifetime of the request or job execution. Defaults to `false`. +#### `config.active_record.query_log_tags_prepend_comment` + +Specifies whether or not to prepend query log tags comment to the query. + +By default comments are appended at the end of the query. Certain databases, such as MySQL will +truncate the query text. This is the case for slow query logs and the results of querying +some InnoDB internal tables where the length of the query is more than 1024 bytes. +In order to not lose the log tags comments from the queries, you can prepend the comments using this option. + +Defaults to `false`. + #### `config.active_record.schema_cache_ignored_tables` Define the list of table that should be ignored when generating the schema diff --git a/railties/test/application/query_logs_test.rb b/railties/test/application/query_logs_test.rb index 4e390d5e04966..de1cb716e75d1 100644 --- a/railties/test/application/query_logs_test.rb +++ b/railties/test/application/query_logs_test.rb @@ -19,7 +19,7 @@ class User < ActiveRecord::Base app_file "app/controllers/users_controller.rb", <<-RUBY class UsersController < ApplicationController def index - render inline: ActiveRecord::QueryLogs.call("", Pet.connection) + render inline: ActiveRecord::QueryLogs.call("SELECT 1", Pet.connection) end def dynamic_content @@ -146,7 +146,7 @@ def app get "/", {}, { "HTTPS" => "on" } comment = last_response.body.strip - assert_equal("/*action='index',controller='users',database='storage%2Fproduction_animals.sqlite3'*/", comment) + assert_equal("SELECT 1 /*action='index',controller='users',database='storage%2Fproduction_animals.sqlite3'*/", comment) end test "source_location information is added if enabled" do @@ -166,6 +166,19 @@ def app assert_match(/source_location='.*\d+'/, comment) end + test "prepending tags comment" do + add_to_config "config.active_record.query_log_tags_enabled = true" + add_to_config "config.active_record.query_log_tags = [ :action, :controller ]" + add_to_config "config.active_record.query_log_tags_prepend_comment = true" + + boot_app + + get "/", {}, { "HTTPS" => "on" } + comment = last_response.body.strip + + assert_match(/\A\/\*action='index',controller='users'\*\/ SELECT 1/, comment) + end + test "controller tags are not doubled up if already configured" do add_to_config "config.active_record.query_log_tags_enabled = true" add_to_config "config.active_record.query_log_tags = [ :action, :job, :controller, :pid ]" @@ -261,7 +274,7 @@ def app get "/", {}, { "HTTPS" => "on" } comment = last_response.body.strip - assert_equal %(/*action='index',controller='users',namespaced_controller='users'*/), comment + assert_equal %(SELECT 1 /*action='index',controller='users',namespaced_controller='users'*/), comment get "/namespaced/users", {}, { "HTTPS" => "on" } comment = last_response.body.strip From c327b19b94a384b298d498fa19c93e68b3add7f9 Mon Sep 17 00:00:00 2001 From: Issy Long Date: Thu, 27 Feb 2025 18:37:12 +0000 Subject: [PATCH 0015/1075] activerecord: Don't always append primary keys etc. to order conditions - If `nil` is the last element of an array passed to `implicit_order_column`, do not append the primary key or the query constraints. For example, `self.implicit_order_column = ["author_name", nil]` will generate `ORDER BY author_name DESC` and not `ORDER BY author_name DESC, id DESC` (with or without a LIMIT). - There wasn't a test for `implicit_order_column` supporting arrays for ordering by multiple columns, so I added one. - The reasoning is that in some cases, like database sharding, the primary key is not the best column to order by for performance reasons and thus users should have the choice of whether to append it by default. --- activerecord/CHANGELOG.md | 10 ++++++++ .../lib/active_record/model_schema.rb | 12 ++++++---- .../active_record/relation/finder_methods.rb | 11 ++++----- activerecord/test/cases/finder_test.rb | 24 ++++++++++++++++++- 4 files changed, 44 insertions(+), 13 deletions(-) diff --git a/activerecord/CHANGELOG.md b/activerecord/CHANGELOG.md index 372fbb3d68a55..f4528f44e09ed 100644 --- a/activerecord/CHANGELOG.md +++ b/activerecord/CHANGELOG.md @@ -1,3 +1,13 @@ +* Allow bypassing primary key/constraint addition in `implicit_order_column` + + When specifying multiple columns in an array for `implicit_order_column`, adding + `nil` as the last element will prevent appending the primary key to order + conditions. This allows more precise control of indexes used by + generated queries. It should be noted that this feature does introduce the risk + of API misbehavior if the specified columns are not fully unique. + + *Issy Long* + * Allow setting the `schema_format` via database configuration. ``` diff --git a/activerecord/lib/active_record/model_schema.rb b/activerecord/lib/active_record/model_schema.rb index 190aac1672767..de5fcbbeb2e52 100644 --- a/activerecord/lib/active_record/model_schema.rb +++ b/activerecord/lib/active_record/model_schema.rb @@ -113,17 +113,19 @@ module ModelSchema # :singleton-method: implicit_order_column # :call-seq: implicit_order_column # - # The name of the column records are ordered by if no explicit order clause + # The name of the column(s) records are ordered by if no explicit order clause # is used during an ordered finder call. If not set the primary key is used. ## # :singleton-method: implicit_order_column= # :call-seq: implicit_order_column=(column_name) # - # Sets the column to sort records by when no explicit order clause is used - # during an ordered finder call. Useful when the primary key is not an - # auto-incrementing integer, for example when it's a UUID. Records are subsorted - # by the primary key if it exists to ensure deterministic results. + # Sets the column(s) to sort records by when no explicit order clause is used + # during an ordered finder call. Useful for models where the primary key isn't an + # auto-incrementing integer (such as UUID). + # + # By default, records are subsorted by primary key to ensure deterministic results. + # To disable this subsort behavior, set `implicit_order_column` to `["column_name", nil]`. ## # :singleton-method: immutable_strings_by_default= diff --git a/activerecord/lib/active_record/relation/finder_methods.rb b/activerecord/lib/active_record/relation/finder_methods.rb index 293ee008ffe5d..89809de8ff9ed 100644 --- a/activerecord/lib/active_record/relation/finder_methods.rb +++ b/activerecord/lib/active_record/relation/finder_methods.rb @@ -646,16 +646,13 @@ def ordered_relation end def _order_columns - oc = [] + columns = Array(model.implicit_order_column) - oc << model.implicit_order_column if model.implicit_order_column - oc << model.query_constraints_list if model.query_constraints_list + return columns.compact if columns.length.positive? && columns.last.nil? - if model.primary_key && model.query_constraints_list.nil? - oc << model.primary_key - end + columns += Array(model.query_constraints_list || model.primary_key) - oc.flatten.uniq.compact + columns.uniq.compact end end end diff --git a/activerecord/test/cases/finder_test.rb b/activerecord/test/cases/finder_test.rb index 3f37ef436c7da..c04d25f6beb0b 100644 --- a/activerecord/test/cases/finder_test.rb +++ b/activerecord/test/cases/finder_test.rb @@ -1106,7 +1106,7 @@ def test_first_have_determined_order_by_default assert_equal expected, clients.order(nil).first(2) end - def test_implicit_order_column_is_configurable + def test_implicit_order_column_is_configurable_with_a_single_value old_implicit_order_column = Topic.implicit_order_column Topic.implicit_order_column = "title" @@ -1120,6 +1120,28 @@ def test_implicit_order_column_is_configurable Topic.implicit_order_column = old_implicit_order_column end + def test_implicit_order_column_is_configurable_with_multiple_values + old_implicit_order_column = Topic.implicit_order_column + Topic.implicit_order_column = ["title", "author_name"] + + assert_queries_match(/ORDER BY #{Regexp.escape(quote_table_name("topics.title"))} DESC, #{Regexp.escape(quote_table_name("topics.author_name"))} DESC, #{Regexp.escape(quote_table_name("topics.id"))} DESC LIMIT/i) { + Topic.last + } + ensure + Topic.implicit_order_column = old_implicit_order_column + end + + def test_ordering_does_not_append_primary_keys_or_query_constraints_if_passed_an_implicit_order_column_array_ending_in_nil + old_implicit_order_column = Topic.implicit_order_column + Topic.implicit_order_column = ["author_name", nil] + + assert_queries_match(/ORDER BY #{Regexp.escape(quote_table_name("topics.author_name"))} DESC LIMIT/i) { + Topic.last + } + ensure + Topic.implicit_order_column = old_implicit_order_column + end + def test_implicit_order_set_to_primary_key old_implicit_order_column = Topic.implicit_order_column Topic.implicit_order_column = "id" From fa41e5f1e0b3101af7234393d0fd97e7a6577c66 Mon Sep 17 00:00:00 2001 From: Austin Story Date: Sat, 8 Mar 2025 08:52:59 -0800 Subject: [PATCH 0016/1075] Add more explicit NotImplementedErrors in AbstractAdapter Adding this so that when folks are adding a new db adapter and run into an error it is explicit for what is not implemented. --- .../lib/active_record/connection_adapters/abstract_adapter.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb b/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb index 55360a653b42b..3583bce2385b4 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb @@ -119,7 +119,7 @@ def self.find_cmd_and_exec(commands, *args) # :doc: # Opens a database console session. def self.dbconsole(config, options = {}) - raise NotImplementedError + raise NotImplementedError.new("#{self.class} should define `dbconsole` that accepts a db config and options to implement connecting to the db console") end def initialize(config_or_deprecated_connection, deprecated_logger = nil, deprecated_connection_options = nil, deprecated_config = nil) # :nodoc: @@ -1084,7 +1084,7 @@ def backoff(counter) end def reconnect - raise NotImplementedError + raise NotImplementedError.new("#{self.class} should define `reconnect` to implement adapter-specific logic for reconnecting to the database") end # Returns a raw connection for internal use with methods that are known From bd12382f6f345088764902f08cbcc7230b091230 Mon Sep 17 00:00:00 2001 From: a5-stable Date: Sun, 9 Mar 2025 11:04:14 +0900 Subject: [PATCH 0017/1075] Implement respond_to_missing? for ActiveRecord::Migration --- activerecord/lib/active_record/migration.rb | 9 +++++++++ activerecord/test/cases/migration_test.rb | 15 +++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/activerecord/lib/active_record/migration.rb b/activerecord/lib/active_record/migration.rb index 11c21523fb28b..88af2811726cd 100644 --- a/activerecord/lib/active_record/migration.rb +++ b/activerecord/lib/active_record/migration.rb @@ -782,6 +782,11 @@ def load_schema! system("bin/rails db:test:prepare") end end + + def respond_to_missing?(method, include_private = false) + return false if nearest_delegate == delegate + nearest_delegate.respond_to?(method, include_private) + end end def disable_ddl_transaction # :nodoc: @@ -1170,6 +1175,10 @@ def internal_option?(option_name) def command_recorder CommandRecorder.new(connection) end + + def respond_to_missing?(method, include_private = false) + execution_strategy.respond_to?(method, include_private) || super + end end # MigrationProxy is used to defer loading of the actual migration classes diff --git a/activerecord/test/cases/migration_test.rb b/activerecord/test/cases/migration_test.rb index cd9bed23740cc..bebde93fa3f92 100644 --- a/activerecord/test/cases/migration_test.rb +++ b/activerecord/test/cases/migration_test.rb @@ -461,6 +461,21 @@ def create_table; "hi mom!"; end assert_equal "hi mom!", migration.method_missing(:create_table) end + def test_respond_to_for_migration_method + migration_class = Class.new(ActiveRecord::Migration::Current) { + def connection + Class.new { + def create_table; end + }.new + end + } + + migration_class.class_eval { undef_method :create_table } + # create_table is handled by method_missing, so respond_to? returns true. + assert migration_class.new.respond_to?(:create_table) + assert migration_class.respond_to?(:create_table) + end + def test_add_table_with_decimals Person.lease_connection.drop_table :big_numbers rescue nil From 93a13fe62c898cffb5f17f00f558c45c617782c5 Mon Sep 17 00:00:00 2001 From: Iuri G <289754+iuri-gg@users.noreply.github.com> Date: Sun, 9 Mar 2025 11:23:58 -0500 Subject: [PATCH 0018/1075] Add additional platforms for gems with native extensions --- Gemfile.lock | 55 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/Gemfile.lock b/Gemfile.lock index 3858e76f44d65..f5ce840156608 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -235,8 +235,14 @@ GEM faraday_middleware (1.2.1) faraday (~> 1.0) ffi (1.17.1) + ffi (1.17.1-aarch64-linux-gnu) + ffi (1.17.1-aarch64-linux-musl) + ffi (1.17.1-arm-linux-gnu) + ffi (1.17.1-arm-linux-musl) + ffi (1.17.1-arm64-darwin) ffi (1.17.1-x86_64-darwin) ffi (1.17.1-x86_64-linux-gnu) + ffi (1.17.1-x86_64-linux-musl) fugit (1.11.1) et-orbi (~> 1, >= 1.2.11) raabro (~> 1.4) @@ -273,6 +279,12 @@ GEM google-protobuf (4.29.3) bigdecimal rake (>= 13) + google-protobuf (4.29.3-aarch64-linux) + bigdecimal + rake (>= 13) + google-protobuf (4.29.3-arm64-darwin) + bigdecimal + rake (>= 13) google-protobuf (4.29.3-x86_64-darwin) bigdecimal rake (>= 13) @@ -400,10 +412,22 @@ GEM nokogiri (1.18.1) mini_portile2 (~> 2.8.2) racc (~> 1.4) + nokogiri (1.18.1-aarch64-linux-gnu) + racc (~> 1.4) + nokogiri (1.18.1-aarch64-linux-musl) + racc (~> 1.4) + nokogiri (1.18.1-arm-linux-gnu) + racc (~> 1.4) + nokogiri (1.18.1-arm-linux-musl) + racc (~> 1.4) + nokogiri (1.18.1-arm64-darwin) + racc (~> 1.4) nokogiri (1.18.1-x86_64-darwin) racc (~> 1.4) nokogiri (1.18.1-x86_64-linux-gnu) racc (~> 1.4) + nokogiri (1.18.1-x86_64-linux-musl) + racc (~> 1.4) os (1.1.4) ostruct (0.6.1) parallel (1.26.3) @@ -532,10 +556,22 @@ GEM sass-embedded (1.83.4) google-protobuf (~> 4.29) rake (>= 13) + sass-embedded (1.83.4-aarch64-linux-gnu) + google-protobuf (~> 4.29) + sass-embedded (1.83.4-aarch64-linux-musl) + google-protobuf (~> 4.29) + sass-embedded (1.83.4-arm-linux-gnueabihf) + google-protobuf (~> 4.29) + sass-embedded (1.83.4-arm-linux-musleabihf) + google-protobuf (~> 4.29) + sass-embedded (1.83.4-arm64-darwin) + google-protobuf (~> 4.29) sass-embedded (1.83.4-x86_64-darwin) google-protobuf (~> 4.29) sass-embedded (1.83.4-x86_64-linux-gnu) google-protobuf (~> 4.29) + sass-embedded (1.83.4-x86_64-linux-musl) + google-protobuf (~> 4.29) securerandom (0.4.1) selenium-webdriver (4.29.1) base64 (~> 0.2) @@ -598,8 +634,14 @@ GEM sprockets (>= 3.0.0) sqlite3 (2.5.0) mini_portile2 (~> 2.8.0) + sqlite3 (2.5.0-aarch64-linux-gnu) + sqlite3 (2.5.0-aarch64-linux-musl) + sqlite3 (2.5.0-arm-linux-gnu) + sqlite3 (2.5.0-arm-linux-musl) + sqlite3 (2.5.0-arm64-darwin) sqlite3 (2.5.0-x86_64-darwin) sqlite3 (2.5.0-x86_64-linux-gnu) + sqlite3 (2.5.0-x86_64-linux-musl) sshkit (1.23.2) base64 net-scp (>= 1.1.2) @@ -616,12 +658,17 @@ GEM railties (>= 7.0.0) tailwindcss-ruby tailwindcss-ruby (3.4.17) + tailwindcss-ruby (3.4.17-aarch64-linux) + tailwindcss-ruby (3.4.17-arm-linux) + tailwindcss-ruby (3.4.17-arm64-darwin) tailwindcss-ruby (3.4.17-x86_64-darwin) tailwindcss-ruby (3.4.17-x86_64-linux) terser (1.2.4) execjs (>= 0.3.0, < 3) thor (1.3.2) thruster (0.1.10) + thruster (0.1.10-aarch64-linux) + thruster (0.1.10-arm64-darwin) thruster (0.1.10-x86_64-darwin) thruster (0.1.10-x86_64-linux) tilt (2.6.0) @@ -669,9 +716,17 @@ GEM zeitwerk (2.7.1) PLATFORMS + aarch64-linux + aarch64-linux-gnu + aarch64-linux-musl + arm-linux-gnu + arm-linux-musl + arm64-darwin ruby x86_64-darwin x86_64-linux + x86_64-linux-gnu + x86_64-linux-musl DEPENDENCIES aws-sdk-s3 From 357bb3839da450a3bbe1003e24c1c341af93b6d3 Mon Sep 17 00:00:00 2001 From: zzak Date: Sun, 29 Sep 2024 20:28:46 +0900 Subject: [PATCH 0019/1075] Add inspect to ActiveStorage::Service, ActiveStorage::Service::Registry, and ActiveStorage::Service::Configurator This was inspired by #50405. If you accidentally pretty print `ActiveStorage::Blob.services` and have the entire config which may contain secrets persit in logs or elsewhere. Or in Ruby 3.2 and older, raising an exception on those objects, e.g. `ActiveStorage::Blob.services[:local]` instead of `fetch`. Before: ```ruby pp ActiveStorage::Blob.services # { :service=>"S3", :access_key_id=>"test_key", :secret_access_key=>"test_secret", :region=>"us-east-1", :bucket=>"rails-test" }, :gcs => { :service=>"GCS", :credentials=> "secret-hash" } } @services={}> ``` After ```ruby # ``` --- activestorage/lib/active_storage/service.rb | 4 ++++ .../active_storage/service/configurator.rb | 6 ++++++ .../lib/active_storage/service/registry.rb | 6 ++++++ .../test/service/configurator_test.rb | 13 ++++++++++++ activestorage/test/service/registry_test.rb | 20 +++++++++++++++++++ activestorage/test/service_test.rb | 18 +++++++++++++++++ 6 files changed, 67 insertions(+) create mode 100644 activestorage/test/service/registry_test.rb create mode 100644 activestorage/test/service_test.rb diff --git a/activestorage/lib/active_storage/service.rb b/activestorage/lib/active_storage/service.rb index 6ed3563cda76f..518c68c46205e 100644 --- a/activestorage/lib/active_storage/service.rb +++ b/activestorage/lib/active_storage/service.rb @@ -148,6 +148,10 @@ def public? @public end + def inspect # :nodoc: + "#<#{self.class}#{name.present? ? " name=#{name.inspect}" : ""}>" + end + private def private_url(key, expires_in:, filename:, disposition:, content_type:, **) raise NotImplementedError diff --git a/activestorage/lib/active_storage/service/configurator.rb b/activestorage/lib/active_storage/service/configurator.rb index 4efb2940f961e..a899ce1e61f20 100644 --- a/activestorage/lib/active_storage/service/configurator.rb +++ b/activestorage/lib/active_storage/service/configurator.rb @@ -19,6 +19,12 @@ def build(service_name) ) end + def inspect # :nodoc: + attrs = configurations.any? ? + " configurations=[#{configurations.keys.map(&:inspect).join(", ")}]" : "" + "#<#{self.class}#{attrs}>" + end + private def config_for(name) configurations.fetch name do diff --git a/activestorage/lib/active_storage/service/registry.rb b/activestorage/lib/active_storage/service/registry.rb index 4ed200e7ca103..fff0b6a950ca4 100644 --- a/activestorage/lib/active_storage/service/registry.rb +++ b/activestorage/lib/active_storage/service/registry.rb @@ -22,6 +22,12 @@ def fetch(name) end end + def inspect # :nodoc: + attrs = configurations.any? ? + " configurations=[#{configurations.keys.map(&:inspect).join(", ")}]" : "" + "#<#{self.class}#{attrs}>" + end + private attr_reader :configurations, :services diff --git a/activestorage/test/service/configurator_test.rb b/activestorage/test/service/configurator_test.rb index 2f5e06cd0556c..3e55406ee4c35 100644 --- a/activestorage/test/service/configurator_test.rb +++ b/activestorage/test/service/configurator_test.rb @@ -21,6 +21,19 @@ class ActiveStorage::Service::ConfiguratorTest < ActiveSupport::TestCase end end + test "inspect attributes" do + config = { + local: { service: "Disk", root: "/tmp/active_storage_configurator_test" }, + tmp: { service: "Disk", root: "/tmp/active_storage_configurator_test_tmp" }, + } + + configurator = ActiveStorage::Service::Configurator.new(config) + assert_match(/#/, configurator.inspect) + + configurator = ActiveStorage::Service::Configurator.new({}) + assert_match(/#/, configurator.inspect) + end + test "azure service is deprecated" do msg = <<~MSG.squish `ActiveStorage::Service::AzureStorageService` is deprecated and will be diff --git a/activestorage/test/service/registry_test.rb b/activestorage/test/service/registry_test.rb new file mode 100644 index 0000000000000..d754eddd75b03 --- /dev/null +++ b/activestorage/test/service/registry_test.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require "test_helper" + +class ActiveStorage::Service::RegistryTest < ActiveSupport::TestCase + test "inspect attributes" do + registry = ActiveStorage::Service::Registry.new({}) + assert_match(/#/, registry.inspect) + end + + test "inspect attributes with config" do + config = { + local: { service: "Disk", root: "/tmp/active_storage_registry_test" }, + tmp: { service: "Disk", root: "/tmp/active_storage_registry_test_tmp" }, + } + + registry = ActiveStorage::Service::Registry.new(config) + assert_match(/#/, registry.inspect) + end +end diff --git a/activestorage/test/service_test.rb b/activestorage/test/service_test.rb new file mode 100644 index 0000000000000..5bc250b48a783 --- /dev/null +++ b/activestorage/test/service_test.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require "test_helper" + +class ActiveStorage::ServiceTest < ActiveSupport::TestCase + test "inspect attributes" do + config = { + local: { service: "Disk", root: "/tmp/active_storage_service_test" }, + tmp: { service: "Disk", root: "/tmp/active_storage_service_test_tmp" }, + } + + service = ActiveStorage::Service.configure(:local, config) + assert_match(/#/, service.inspect) + + service = ActiveStorage::Service.new + assert_match(/#/, service.inspect) + end +end From 2aba1f5ceb0d59fa1b12f2c2fd5b9ea0eb582f59 Mon Sep 17 00:00:00 2001 From: Joshua Young Date: Mon, 10 Mar 2025 17:43:44 +1000 Subject: [PATCH 0020/1075] Handle `PG::ConnectionBad` from `PG#server_version` similarly to version 0 --- .../connection_adapters/postgresql_adapter.rb | 2 +- .../postgresql/postgresql_adapter_test.rb | 25 +++++++++++++++++-- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb b/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb index 2cc185ae70912..641c614911ff7 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb @@ -636,7 +636,7 @@ def get_database_version # :nodoc: with_raw_connection do |conn| version = conn.server_version if version == 0 - raise ActiveRecord::ConnectionFailed, "Could not determine PostgreSQL version" + raise ActiveRecord::ConnectionNotEstablished, "Could not determine PostgreSQL version" end version end diff --git a/activerecord/test/cases/adapters/postgresql/postgresql_adapter_test.rb b/activerecord/test/cases/adapters/postgresql/postgresql_adapter_test.rb index 4819915aadd17..73983d0dc17c9 100644 --- a/activerecord/test/cases/adapters/postgresql/postgresql_adapter_test.rb +++ b/activerecord/test/cases/adapters/postgresql/postgresql_adapter_test.rb @@ -95,7 +95,7 @@ def test_bad_connection_to_postgres_database end end - def test_reconnect_after_bad_connection_on_check_version + def test_reconnect_after_bad_connection_on_check_version_with_0_return db_config = ActiveRecord::Base.configurations.configs_for(env_name: "arunit", name: "primary") connection = ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.new(db_config.configuration_hash.merge(connection_retries: 0)) connection.connect! @@ -103,7 +103,7 @@ def test_reconnect_after_bad_connection_on_check_version # mimic a connection that hasn't checked and cached the server version yet i.e. without a raw_connection connection.pool.instance_variable_set(:@server_version, nil) connection.raw_connection.stub(:server_version, 0) do - error = assert_raises ActiveRecord::ConnectionFailed do + error = assert_raises ActiveRecord::ConnectionNotEstablished do connection.reconnect! end assert_equal "Could not determine PostgreSQL version", error.message @@ -115,6 +115,27 @@ def test_reconnect_after_bad_connection_on_check_version end end + def test_reconnect_after_bad_connection_on_check_version_with_native_exception + db_config = ActiveRecord::Base.configurations.configs_for(env_name: "arunit", name: "primary") + connection = ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.new(db_config.configuration_hash.merge(connection_retries: 0)) + connection.connect! + + # mimic a connection that hasn't checked and cached the server version yet i.e. without a raw_connection + connection.pool.instance_variable_set(:@server_version, nil) + # https://github.com/ged/ruby-pg/commit/a565e153d4d05955342ad24d4845378eee956935 + connection.raw_connection.stub(:server_version, -> { raise PG::ConnectionBad, "PQserverVersion() can't get server version" }) do + error = assert_raises ActiveRecord::ConnectionNotEstablished do + connection.reconnect! + end + assert_equal "PQserverVersion() can't get server version", error.message + end + + # can reconnect after a bad connection + assert_nothing_raised do + connection.reconnect! + end + end + def test_database_exists_returns_false_when_the_database_does_not_exist config = { database: "non_extant_database", adapter: "postgresql" } assert_not ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.database_exists?(config), From b1623770328643593996ab4ffdd2d12662dcc466 Mon Sep 17 00:00:00 2001 From: Yasuo Honda Date: Tue, 11 Mar 2025 12:33:34 +0000 Subject: [PATCH 0021/1075] Bump bundler inside the devcontainer This commit updates the Bundler version in Gemfile.lock and modifies the boot.sh script to install the latest Bundler version to avoid similar warnings in the future. - Steps to reproduce 1. Startup the devcontainer 2. Run ```bash $ bundle exec rake test ``` - Actual result This results in the following warning: ``` /home/vscode/.rbenv/versions/3.4.2/lib/ruby/gems/3.4.0/gems/bundler-2.5.16/lib/bundler/rubygems_ext.rb:250: warning: method redefined; discarding old encode_with /home/vscode/.rbenv/versions/3.4.2/lib/ruby/3.4.0/rubygems/dependency.rb:341: warning: previous definition of encode_with was here ``` - Background: When Ruby is upgraded, the gem command is updated as well. However, Bundler installs the version specified in Gemfile.lock under BUNDLED WITH, which can cause outdated versions and trigger the warning above. The fix in rubygems/rubygems#7867 has been available since RubyGems 3.6.0 and Bundler 2.6.0. Since Bundler 2.5.16 lacks this commit, upgrading is necessary to prevent these warnings. --- .devcontainer/boot.sh | 1 + Gemfile.lock | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.devcontainer/boot.sh b/.devcontainer/boot.sh index e84447064ebb8..ee03e3678a5e6 100755 --- a/.devcontainer/boot.sh +++ b/.devcontainer/boot.sh @@ -1,5 +1,6 @@ #!/bin/sh +bundle update --bundler bundle install if [ -n "${NVM_DIR}" ]; then diff --git a/Gemfile.lock b/Gemfile.lock index f5ce840156608..a075b1cc9e5c8 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -815,4 +815,4 @@ DEPENDENCIES websocket-client-simple BUNDLED WITH - 2.5.16 + 2.6.5 From c5227df07a088b642df3918d6bfdccab66c7e5ae Mon Sep 17 00:00:00 2001 From: Pedro Paiva Date: Sun, 9 Mar 2025 14:49:10 -0300 Subject: [PATCH 0022/1075] docs: add SolidCable adapter configuration steps --- guides/source/action_cable_overview.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/guides/source/action_cable_overview.md b/guides/source/action_cable_overview.md index 85e5d1bbdc9dc..bb5350d07dd95 100644 --- a/guides/source/action_cable_overview.md +++ b/guides/source/action_cable_overview.md @@ -751,6 +751,10 @@ The async adapter is intended for development/testing and should not be used in NOTE: The async adapter only works within the same process, so for manually triggering cable updates from a console and seeing results in the browser, you must do so from the web console (running inside the dev process), not a terminal started via `bin/rails console`! Add `console` to any action or any ERB template view to make the web console appear. +##### Solid Cable Adapter + +The Solid Cable adapter is a database-backed solution that uses Active Record. It has been tested with MySQL, SQLite, and PostgreSQL. Running `bin/rails solid_cable:install` will automatically set up `config/cable.yml` and create `db/cable_schema.rb`. After that, you must manually update `config/database.yml`, adjusting it based on your database. See [Solid Cable Installation](https://github.com/rails/solid_cable?tab=readme-ov-file#installation). + ##### Redis Adapter The Redis adapter requires users to provide a URL pointing to the Redis server. From c356b42b622ac3ece7665f34b73b3888f79defe3 Mon Sep 17 00:00:00 2001 From: nanaya Date: Mon, 13 Jan 2025 18:03:16 +0900 Subject: [PATCH 0023/1075] Update PostgreSQL UUID documentation [ci-skip] The function has been moved to core since version 13. --- .../postgresql/schema_definitions.rb | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) 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 9cca8060758d6..a285adeb2992d 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/schema_definitions.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/schema_definitions.rb @@ -16,22 +16,10 @@ module ColumnMethods # t.timestamps # end # - # By default, this will use the gen_random_uuid() function from the - # +pgcrypto+ extension. As that extension is only available in - # PostgreSQL 9.4+, for earlier versions an explicit default can be set - # to use uuid_generate_v4() from the +uuid-ossp+ extension instead: + # By default, this will use the gen_random_uuid() function. # - # create_table :stuffs, id: false do |t| - # t.primary_key :id, :uuid, default: "uuid_generate_v4()" - # t.uuid :foo_id - # t.timestamps - # end - # - # To enable the appropriate extension, which is a requirement, use - # the +enable_extension+ method in your migrations. - # - # To use a UUID primary key without any of the extensions, set the - # +:default+ option to +nil+: + # To use a UUID primary key without any defaults, set the +:default+ + # option to +nil+: # # create_table :stuffs, id: false do |t| # t.primary_key :id, :uuid, default: nil From dc4f4e059a862638da18572c39417e31afd0420b Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 11 Mar 2025 17:38:00 +0100 Subject: [PATCH 0024/1075] Make importmap changes invalidate HTML etags by default (#54021) --- railties/lib/rails/generators/app_base.rb | 7 +++++-- .../templates/app/controllers/application_controller.rb.tt | 5 +++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/railties/lib/rails/generators/app_base.rb b/railties/lib/rails/generators/app_base.rb index 2dac1700926d8..c9162a4a00861 100644 --- a/railties/lib/rails/generators/app_base.rb +++ b/railties/lib/rails/generators/app_base.rb @@ -513,9 +513,12 @@ def hotwire_gemfile_entry [ turbo_rails_entry, stimulus_rails_entry ] end + def using_importmap? + options[:javascript] == "importmap" + end + def using_js_runtime? - (options[:javascript] && !%w[importmap].include?(options[:javascript])) || - (options[:css] && !%w[tailwind sass].include?(options[:css])) + (options[:javascript] && !using_importmap?) || (options[:css] && !%w[tailwind sass].include?(options[:css])) end def using_node? diff --git a/railties/lib/rails/generators/rails/app/templates/app/controllers/application_controller.rb.tt b/railties/lib/rails/generators/rails/app/templates/app/controllers/application_controller.rb.tt index 592aec8a31520..e9365f994caaf 100644 --- a/railties/lib/rails/generators/rails/app/templates/app/controllers/application_controller.rb.tt +++ b/railties/lib/rails/generators/rails/app/templates/app/controllers/application_controller.rb.tt @@ -2,5 +2,10 @@ class ApplicationController < ActionController::<%= options.api? ? "API" : "Base <%- unless options.api? -%> # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has. allow_browser versions: :modern +<%- if options.using_importmap? -%> + + # Changes to the importmap will invalidate the etag for HTML responses + stale_when_importmap_changes +<% end -%> <% end -%> end From e87af0f3235ca555f7864de9bb44da1d464d012d Mon Sep 17 00:00:00 2001 From: Rami Massoud Date: Tue, 11 Mar 2025 14:21:15 -0400 Subject: [PATCH 0025/1075] Generate mailer files in auth generator only if ActionMailer is used If an app does not use ActionMailer, then all of the mailer-related code is unnecessary. Also if the mailer files are generated, the PasswordMailer will inherit from an ApplicationMailer that doesn't exist, breaking the application in a way that is not obvious until the mailer is run or the app is deployed to an environment with eager class loading Fixes #54501 --- railties/CHANGELOG.md | 5 +++++ .../authentication_generator.rb | 11 ++++++---- .../controllers/passwords_controller.rb.tt | 4 ++++ .../authentication_generator_test.rb | 20 +++++++++++++++++++ 4 files changed, 36 insertions(+), 4 deletions(-) diff --git a/railties/CHANGELOG.md b/railties/CHANGELOG.md index 984e295ad47d5..103edb9682428 100644 --- a/railties/CHANGELOG.md +++ b/railties/CHANGELOG.md @@ -1,3 +1,8 @@ +* Skip generating mailer-related files in authentication generator if the application does + not use ActionMailer + + *Rami Massoud* + * Introduce `bin/ci` for running your tests, style checks, and security audits locally or in the cloud. The specific steps are defined by a new DSL in `config/ci.rb`. diff --git a/railties/lib/rails/generators/rails/authentication/authentication_generator.rb b/railties/lib/rails/generators/rails/authentication/authentication_generator.rb index ad7524da968ca..6defc45987ea9 100644 --- a/railties/lib/rails/generators/rails/authentication/authentication_generator.rb +++ b/railties/lib/rails/generators/rails/authentication/authentication_generator.rb @@ -25,12 +25,15 @@ def create_authentication_files template "app/channels/application_cable/connection.rb" if defined?(ActionCable::Engine) - template "app/mailers/passwords_mailer.rb" + if defined?(ActionMailer::Railtie) + template "app/mailers/passwords_mailer.rb" - template "app/views/passwords_mailer/reset.html.erb" - template "app/views/passwords_mailer/reset.text.erb" + template "app/views/passwords_mailer/reset.html.erb" + template "app/views/passwords_mailer/reset.text.erb" + + template "test/mailers/previews/passwords_mailer_preview.rb" + end - template "test/mailers/previews/passwords_mailer_preview.rb" template "test/test_helpers/session_test_helper.rb" end diff --git a/railties/lib/rails/generators/rails/authentication/templates/app/controllers/passwords_controller.rb.tt b/railties/lib/rails/generators/rails/authentication/templates/app/controllers/passwords_controller.rb.tt index f95ec7874d9c0..7fdcfc86c8fe4 100644 --- a/railties/lib/rails/generators/rails/authentication/templates/app/controllers/passwords_controller.rb.tt +++ b/railties/lib/rails/generators/rails/authentication/templates/app/controllers/passwords_controller.rb.tt @@ -1,10 +1,13 @@ class PasswordsController < ApplicationController allow_unauthenticated_access before_action :set_user_by_token, only: %i[ edit update ] + <% if defined?(ActionMailer::Railtie) -%> rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_password_path, alert: "Try again later." } + <% end -%> def new end + <% if defined?(ActionMailer::Railtie) -%> def create if user = User.find_by(email_address: params[:email_address]) @@ -13,6 +16,7 @@ class PasswordsController < ApplicationController redirect_to new_session_path, notice: "Password reset instructions sent (if user with that email address exists)." end + <% end -%> def edit end diff --git a/railties/test/generators/authentication_generator_test.rb b/railties/test/generators/authentication_generator_test.rb index b1eca76bf2c34..0d6e3c2b73eb7 100644 --- a/railties/test/generators/authentication_generator_test.rb +++ b/railties/test/generators/authentication_generator_test.rb @@ -138,6 +138,26 @@ def test_connection_class_skipped_without_action_cable ActionCable.const_set(:Engine, old_value) end + def test_authentication_generator_without_action_mailer + old_value = ActionMailer.const_get(:Railtie) + ActionMailer.send(:remove_const, :Railtie) + generator([destination_root]) + run_generator_instance + + assert_no_file "app/mailers/application_mailer.rb" + assert_no_file "app/mailers/passwords_mailer.rb" + assert_no_file "app/views/passwords_mailer/reset.html.erb" + assert_no_file "app/views/passwords_mailer/reset.text.erb" + assert_no_file "test/mailers/previews/passwords_mailer_preview.rb" + + assert_file "app/controllers/passwords_controller.rb" do |content| + assert_no_match(/def create\n end/, content) + assert_no_match(/rate_limit/, content) + end + ensure + ActionCable.const_set(:Railtie, old_value) + end + private def run_generator_instance @bundle_commands = [] From a7539d8d67f027a7fa12d4abce588df1e9d97d0c Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Tue, 11 Mar 2025 20:15:29 +0100 Subject: [PATCH 0026/1075] Revert "Modify the Test Runner to allow running test at root:" --- railties/lib/rails/test_unit/runner.rb | 16 ++----- railties/test/application/test_runner_test.rb | 48 ------------------- 2 files changed, 5 insertions(+), 59 deletions(-) diff --git a/railties/lib/rails/test_unit/runner.rb b/railties/lib/rails/test_unit/runner.rb index 27e6c3a710251..7ae989f10d038 100644 --- a/railties/lib/rails/test_unit/runner.rb +++ b/railties/lib/rails/test_unit/runner.rb @@ -91,22 +91,12 @@ def compose_filter(runnable, filter) private def extract_filters(argv) - previous_arg_was_a_flag = false # Extract absolute and relative paths but skip -n /.*/ regexp filters. argv.filter_map do |path| - current_arg_is_a_flag = /^-{1,2}[a-zA-Z0-9\-_.]+=?.*\Z/.match?(path) - - if previous_arg_was_a_flag && !current_arg_is_a_flag - # Handle the case where a flag is followed by another flag (e.g. --fail-fast --seed ...) - previous_arg_was_a_flag = false - next - end + next unless path_argument?(path) path = path.tr("\\", "/") case - when current_arg_is_a_flag - previous_arg_was_a_flag = true unless path.include?("=") # Handle the case when "--foo=bar" is used. - next when /(:\d+(-\d+)?)+$/.match?(path) file, *lines = path.split(":") filters << [ file, lines ] @@ -132,6 +122,10 @@ def regexp_filter?(arg) arg.start_with?("/") && arg.end_with?("/") end + def path_argument?(arg) + PATH_ARGUMENT_PATTERN.match?(arg) + end + def list_tests(patterns) tests = Rake::FileList[patterns.any? ? patterns : default_test_glob] tests.exclude(default_test_exclude_glob) if patterns.empty? diff --git a/railties/test/application/test_runner_test.rb b/railties/test/application/test_runner_test.rb index 85b8a71c4b5fb..cf046ab4480ad 100644 --- a/railties/test/application/test_runner_test.rb +++ b/railties/test/application/test_runner_test.rb @@ -222,54 +222,6 @@ def test_sanae end end - def test_run_test_at_root - app_file "my_test.rb", <<-RUBY - require "test_helper" - - class MyTest < ActiveSupport::TestCase - def test_rikka - puts 'Rikka' - end - end - RUBY - - run_test_command("my_test.rb").tap do |output| - assert_match "Rikka", output - end - end - - def test_run_test_having_a_slash_in_its_name - app_file "my_test.rb", <<-RUBY - require "test_helper" - - class MyTest < ActiveSupport::TestCase - test "foo/foo" do - puts 'Rikka' - end - end - RUBY - - run_test_command("my_test.rb -n foo\/foo").tap do |output| - assert_match "Rikka", output - end - end - - def test_run_test_with_flags_unordered - app_file "my_test.rb", <<-RUBY - require "test_helper" - - class MyTest < ActiveSupport::TestCase - test "foo/foo" do - puts 'Rikka' - end - end - RUBY - - run_test_command("--seed 344 my_test.rb --fail-fast -n foo\/foo").tap do |output| - assert_match "Rikka", output - end - end - def test_run_matched_test app_file "test/unit/chu_2_koi_test.rb", <<-RUBY require "test_helper" From d4f3a45260780aa87fc95ffc70e83176860a09e4 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Tue, 11 Mar 2025 10:26:44 -0400 Subject: [PATCH 0027/1075] fix: sqlite3 adapter quotes Infinity and NaN This is similar to the fix applied to the postgresql adapter in #3713 This isn't a problem if the value is provided as a parameter to a prepared statement, but is a problem where literal SQL is assembled, for example with `upsert`. Without quoting, the exception raised is: ``` >> Shirt.upsert({id: 1, size: Float::INFINITY}) sqlite3-2.6.0-x86_64-linux-gnu/lib/sqlite3/statement.rb:36:in 'SQLite3::Statement#prepare': no such column: Infinity: (SQLite3::SQLException) INSERT INTO "shirts" ("id","size","created_at","updated_at") VALUES (1, Infinity, STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW'), STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')) ON CONFLICT ("id") DO UPDATE SET updated_at=(CASE WHEN ("size" IS excluded."size") THEN "shirts".updated_at ELSE STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') END),"size"=excluded."size" RETURNING "id" ^ from sqlite3-2.6.0-x86_64-linux-gnu/lib/sqlite3/statement.rb:36:in 'SQLite3::Statement#initialize' from sqlite3-2.6.0-x86_64-linux-gnu/lib/sqlite3/database.rb:216:in 'Class#new' from sqlite3-2.6.0-x86_64-linux-gnu/lib/sqlite3/database.rb:216:in 'SQLite3::Database#prepare' from rails/activerecord/lib/active_record/connection_adapters/sqlite3/database_statements.rb:94:in 'ActiveRecord::ConnectionAdapters::SQLite3::DatabaseStatements#perform_query' ... from rails/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb:558:in 'block in ActiveRecord::ConnectionAdapters::DatabaseStatements#raw_execute' ... from rails/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb:557:in 'ActiveRecord::ConnectionAdapters::DatabaseStatements#raw_execute' from rails/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb:601:in 'ActiveRecord::ConnectionAdapters::DatabaseStatements#internal_execute' from rails/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb:550:in 'ActiveRecord::ConnectionAdapters::DatabaseStatements#internal_exec_query' from rails/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb:180:in 'ActiveRecord::ConnectionAdapters::DatabaseStatements#exec_insert_all' from rails/activerecord/lib/active_record/connection_adapters/abstract/query_cache.rb:27:in 'ActiveRecord::ConnectionAdapters::AbstractAdapter#exec_insert_all' from rails/activerecord/lib/active_record/insert_all.rb:54:in 'ActiveRecord::InsertAll#execute' from rails/activerecord/lib/active_record/insert_all.rb:13:in 'block in ActiveRecord::InsertAll.execute' ... ``` Quoting this value of "Infinity" (and also "NaN") does what's intended. --- .../connection_adapters/sqlite3/quoting.rb | 13 +++++++++++++ .../test/cases/adapters/sqlite3/quoting_test.rb | 12 ++++++++++++ 2 files changed, 25 insertions(+) diff --git a/activerecord/lib/active_record/connection_adapters/sqlite3/quoting.rb b/activerecord/lib/active_record/connection_adapters/sqlite3/quoting.rb index 7308b64619b8c..0aaef16b5f2a5 100644 --- a/activerecord/lib/active_record/connection_adapters/sqlite3/quoting.rb +++ b/activerecord/lib/active_record/connection_adapters/sqlite3/quoting.rb @@ -50,6 +50,19 @@ def quote_table_name(name) end end + def quote(value) # :nodoc: + case value + when Numeric + if value.finite? + super + else + "'#{value}'" + end + else + super + end + end + def quote_string(s) ::SQLite3::Database.quote(s) end diff --git a/activerecord/test/cases/adapters/sqlite3/quoting_test.rb b/activerecord/test/cases/adapters/sqlite3/quoting_test.rb index fb6b912bd6820..12dc8772a1ec1 100644 --- a/activerecord/test/cases/adapters/sqlite3/quoting_test.rb +++ b/activerecord/test/cases/adapters/sqlite3/quoting_test.rb @@ -95,4 +95,16 @@ def test_quoted_time_dst_local end end end + + def test_quote_numeric_infinity + assert_equal "'Infinity'", @conn.quote(Float::INFINITY) + assert_equal "'-Infinity'", @conn.quote(-Float::INFINITY) + assert_equal "'Infinity'", @conn.quote(BigDecimal(Float::INFINITY)) + assert_equal "'-Infinity'", @conn.quote(BigDecimal(-Float::INFINITY)) + end + + def test_quote_float_nan + assert_equal "'NaN'", @conn.quote(Float::NAN) + assert_equal "'NaN'", @conn.quote(BigDecimal(Float::NAN)) + end end From db612a31a7bb328d56f79aa47f1bd58d1e879f9f Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Tue, 11 Mar 2025 21:57:55 +0100 Subject: [PATCH 0028/1075] Fix AuthenticationGeneratorTest Reassign the Railtie into the right constant. --- railties/test/generators/authentication_generator_test.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/railties/test/generators/authentication_generator_test.rb b/railties/test/generators/authentication_generator_test.rb index 0d6e3c2b73eb7..6d5e722cb3493 100644 --- a/railties/test/generators/authentication_generator_test.rb +++ b/railties/test/generators/authentication_generator_test.rb @@ -155,7 +155,7 @@ def test_authentication_generator_without_action_mailer assert_no_match(/rate_limit/, content) end ensure - ActionCable.const_set(:Railtie, old_value) + ActionMailer.const_set(:Railtie, old_value) end private From c9758b26f135059c6ffdac666f17a20b348108f1 Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Wed, 12 Mar 2025 11:59:48 +0100 Subject: [PATCH 0029/1075] AbstractAdapter#attempt_configure_connection: handle Timeout.timeout If `configure_connection` fails because of `Timeout.timeout`, the raised exception is `Timeout::ExitError` which directly inherits `Exception`, so we must rescue `Exception`. --- .../connection_adapters/abstract_adapter.rb | 2 +- activerecord/test/cases/adapter_test.rb | 23 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb b/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb index 55360a653b42b..5ab5dc3356b78 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb @@ -1221,7 +1221,7 @@ def configure_connection def attempt_configure_connection configure_connection - rescue + rescue Exception # Need to handle things such as Timeout::ExitException disconnect! raise end diff --git a/activerecord/test/cases/adapter_test.rb b/activerecord/test/cases/adapter_test.rb index 8d2c4138441c9..1ab6ed4e6e2a8 100644 --- a/activerecord/test/cases/adapter_test.rb +++ b/activerecord/test/cases/adapter_test.rb @@ -894,6 +894,29 @@ def teardown connection&.disconnect! end + test "disconnect and recover on #configure_connection timeout" do + connection = ActiveRecord::Base.connection_pool.send(:new_connection) + + slow = [5] + connection.singleton_class.define_method(:configure_connection) do + if duration = slow.pop + sleep duration + end + super() + end + + assert_raises Timeout::Error do + Timeout.timeout(0.2) do + connection.exec_query("SELECT 1") + end + end + + assert_equal [[1]], connection.exec_query("SELECT 1").rows + assert_empty failures + ensure + connection&.disconnect! + end + private def raw_transaction_open?(connection) case connection.adapter_name From 84e64e3f9a0ae91fbd7d6af40008d38ea09dfb24 Mon Sep 17 00:00:00 2001 From: Andrey Sobolev Date: Wed, 12 Mar 2025 14:14:16 +0300 Subject: [PATCH 0030/1075] Fix typo in Action Controller Advanced Topics guide --- guides/source/action_controller_advanced_topics.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/guides/source/action_controller_advanced_topics.md b/guides/source/action_controller_advanced_topics.md index b33c0fe851b3f..cfbb7c28d9abd 100644 --- a/guides/source/action_controller_advanced_topics.md +++ b/guides/source/action_controller_advanced_topics.md @@ -105,7 +105,7 @@ Starting with version 7.2, Rails controllers use [`allow_browser`](https://api.r ```ruby class ApplicationController < ActionController::Base - # Only allow modern browsers supporting webp images, web push, badges, import # maps, CSS nesting, and CSS :has. + # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has. allow_browser versions: :modern end ``` From f575fca24a72cf0631c59ed797c575392fbbc527 Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Wed, 12 Mar 2025 14:14:07 +0100 Subject: [PATCH 0031/1075] Update json gem --- Gemfile.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index a075b1cc9e5c8..eb24f25b467bf 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -321,7 +321,7 @@ GEM jmespath (1.6.2) jsbundling-rails (1.3.1) railties (>= 6.0.0) - json (2.10.0) + json (2.10.2) jwt (2.10.1) base64 kamal (2.4.0) @@ -815,4 +815,4 @@ DEPENDENCIES websocket-client-simple BUNDLED WITH - 2.6.5 + 2.6.2 From b1589fa7d9feac53ad2b957a240a88d0f8288244 Mon Sep 17 00:00:00 2001 From: Edouard CHIN Date: Wed, 12 Mar 2025 18:28:35 +0100 Subject: [PATCH 0032/1075] Let minitest parse ARGV: - ### Problem See #54647 for the original reason this needs to be fixed. ### Context #54647 was reverted as we thought it broke running `bin/rails test test:system` where in fact this command was never a valid command, but it was previously silently ignoring the `test:system` arg (anything not considered a path is discarded). #54647 was in some way a better behaviour as it was at least failing loudly. Whether `bin/rails test test:system` should be supported (as discussed [here](https://github.com/rails/rails/pull/54736#issuecomment-2716156252)), is a different problem that is out of scope of this patch. Ultimately, #54647 implementation was not broken but the solution wasn't elegant and didn't reach any consensus, so I figured let's try a new approach. ### Solution Basically the issue is that it's impossible for the Rails test command to know which arguments should be parsed and which should be proxied to minitest. And what it tries to achieve is providing some sort of CLI that minitest lacks. This implementation relies on letting minitest parse all options **and then** we load the files. Previously it was the opposite, where the rails runner would try to guess what files needs to be required, and then let minitest parse all options. This is done using a "catch-all non-flag arguments" to minitest option parser. -------------------- Now that we delegate the parsing of ARGV to minitest, I had to workaround two issues: 1) Running any test subcommand such as `bin/rails test:system` is the equivalent of `bin/rails test test/system`, but in the former, ARGV is empty and the "test/system" string is passed as a ruby parameter to the main test command. So we need to mutate ARGV. *But* mutating argv can only be done inside a `at_exit` hook, because any mutation would be rolled back when the command exists. This happens before minitest has run any test (at process exit). https://github.com/rails/rails/blob/f575fca24a72cf0631c59ed797c575392fbbc527/railties/lib/rails/command.rb#L140-L146 2) Running a test **without** the rails command: `ruby test/my_test.rb` would end up loading the `my_test.rb` file twice. First, because of `ruby`, and the because of our minitest plugin. So we need to let our plugin know whether loading file is required (e.g. did the user used the CLI). --- railties/lib/minitest/rails_plugin.rb | 9 +++ railties/lib/rails/test_unit/runner.rb | 13 ++-- railties/test/application/test_runner_test.rb | 63 +++++++++++++++++++ 3 files changed, 80 insertions(+), 5 deletions(-) diff --git a/railties/lib/minitest/rails_plugin.rb b/railties/lib/minitest/rails_plugin.rb index ea54d1e006595..7feebd515cd71 100644 --- a/railties/lib/minitest/rails_plugin.rb +++ b/railties/lib/minitest/rails_plugin.rb @@ -127,6 +127,11 @@ def self.plugin_rails_options(opts, options) options[:profile] = count end + opts.on(/^[^-]/) do |test_file| + options[:test_files] ||= [] + options[:test_files] << test_file + end + options[:color] = true options[:output_inline] = true end @@ -137,6 +142,10 @@ def self.plugin_rails_init(options) # Don't mess with Minitest unless RAILS_ENV is set return unless ENV["RAILS_ENV"] || ENV["RAILS_MINITEST_PLUGIN"] + if ::Rails::TestUnit::Runner.load_test_files + ::Rails::TestUnit::Runner.load_tests(options.fetch(:test_files, [])) + end + unless options[:full_backtrace] # Plugin can run without Rails loaded, check before filtering. if ::Rails.respond_to?(:backtrace_cleaner) diff --git a/railties/lib/rails/test_unit/runner.rb b/railties/lib/rails/test_unit/runner.rb index 7ae989f10d038..3aa613bf17cd6 100644 --- a/railties/lib/rails/test_unit/runner.rb +++ b/railties/lib/rails/test_unit/runner.rb @@ -26,6 +26,7 @@ class Runner TEST_FOLDERS = [:models, :helpers, :channels, :controllers, :mailers, :integration, :jobs, :mailboxes] PATH_ARGUMENT_PATTERN = %r"^(?!/.+/$)[.\w]*[/\\]" mattr_reader :filters, default: [] + mattr_reader :load_test_files, default: false class << self def attach_before_load_options(opts) @@ -52,10 +53,14 @@ def run_from_rake(test_command, argv = []) success || exit(false) end - def run(argv = []) - load_tests(argv) - + def run(args = []) require "active_support/testing/autorun" + + @@load_test_files = true + + at_exit do + ARGV.replace(args) + end end def load_tests(argv) @@ -93,8 +98,6 @@ def compose_filter(runnable, filter) def extract_filters(argv) # Extract absolute and relative paths but skip -n /.*/ regexp filters. argv.filter_map do |path| - next unless path_argument?(path) - path = path.tr("\\", "/") case when /(:\d+(-\d+)?)+$/.match?(path) diff --git a/railties/test/application/test_runner_test.rb b/railties/test/application/test_runner_test.rb index cf046ab4480ad..5929bf05ebdba 100644 --- a/railties/test/application/test_runner_test.rb +++ b/railties/test/application/test_runner_test.rb @@ -222,6 +222,69 @@ def test_sanae end end + def test_run_test_at_root + app_file "my_test.rb", <<-RUBY + require "test_helper" + + class MyTest < ActiveSupport::TestCase + def test_rikka + puts 'Rikka' + end + end + RUBY + + run_test_command("my_test.rb").tap do |output| + assert_match "Rikka", output + end + end + + def test_run_test_having_a_slash_in_its_name + app_file "my_test.rb", <<-RUBY + require "test_helper" + + class MyTest < ActiveSupport::TestCase + test "foo/foo" do + puts 'Rikka' + end + end + RUBY + + run_test_command("my_test.rb -n foo\/foo").tap do |output| + assert_match "Rikka", output + end + end + + def test_run_test_with_flags_unordered + app_file "my_test.rb", <<-RUBY + require "test_helper" + + class MyTest < ActiveSupport::TestCase + test "foo/foo" do + puts 'Rikka' + end + end + RUBY + + run_test_command("--seed 344 my_test.rb --fail-fast -n foo\/foo").tap do |output| + assert_match "Rikka", output + end + end + + def test_run_test_after_a_flag_without_argument + app_file "my_test.rb", <<-RUBY + require "test_helper" + class MyTest < ActiveSupport::TestCase + test "foo/foo" do + puts 'Rikka' + end + end + RUBY + + run_test_command("--fail-fast my_test.rb -n foo\/foo").tap do |output| + assert_match "Rikka", output + end + end + def test_run_matched_test app_file "test/unit/chu_2_koi_test.rb", <<-RUBY require "test_helper" From ece635b6a2ac3f94c4536531e45308ecf66ab4da Mon Sep 17 00:00:00 2001 From: Edouard CHIN Date: Thu, 13 Mar 2025 01:03:34 +0100 Subject: [PATCH 0033/1075] Split `bin/rails test test:system` into two steps: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `bin/rails test test:system` is not a thing. The `test:system` part is silently discarded. This should be loudly failing with the change I'm trying to add in https://github.com/rails/rails/pull/54741, and there are discussions in #54736 whether running multiple commands à la rake should be supported. But in the meantime, modifying this into two steps would ensure your system tests actually run. --- .../lib/rails/generators/rails/app/templates/config/ci.rb.tt | 3 ++- railties/test/application/bin_ci_test.rb | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/railties/lib/rails/generators/rails/app/templates/config/ci.rb.tt b/railties/lib/rails/generators/rails/app/templates/config/ci.rb.tt index 6acc15e8e5182..9eec2d020826e 100644 --- a/railties/lib/rails/generators/rails/app/templates/config/ci.rb.tt +++ b/railties/lib/rails/generators/rails/app/templates/config/ci.rb.tt @@ -19,7 +19,8 @@ CI.run do <% if options[:api] || options[:skip_system_test] -%> step "Tests: Rails", "bin/rails test" <% else %> - step "Tests: Rails", "bin/rails test test:system" + step "Tests: Rails", "bin/rails test" + step "Tests: System", "bin/rails test:system" <% end -%> step "Tests: Seeds", "env RAILS_ENV=test bin/rails db:seed:replant" diff --git a/railties/test/application/bin_ci_test.rb b/railties/test/application/bin_ci_test.rb index f2340faf703e8..37fb22e211e87 100644 --- a/railties/test/application/bin_ci_test.rb +++ b/railties/test/application/bin_ci_test.rb @@ -19,7 +19,8 @@ class BinCiTest < ActiveSupport::TestCase # Default steps assert_match(/bin\/rubocop/, content) assert_match(/bin\/brakeman/, content) - assert_match(/bin\/rails test/, content) + assert_match(/"bin\/rails test"$/, content) + assert_match(/"bin\/rails test:system"$/, content) assert_match(/bin\/rails db:seed:replant/, content) # Node-specific steps excluded by default From d3bb4326975acffe49e80264f39be6062fb0ea11 Mon Sep 17 00:00:00 2001 From: George Ma Date: Wed, 12 Mar 2025 20:29:54 -0400 Subject: [PATCH 0034/1075] Fix regression in ActiveRecord::Result#column_types When a column type is nil in the original column_types array, the new implementation was returning nil instead of falling back to the default type. This caused "undefined method 'deserialize' for nil" errors when accessing model attributes through ActiveModel::LazyAttributeSet#fetch_value. --- activerecord/lib/active_record/result.rb | 3 ++- activerecord/test/cases/result_test.rb | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/activerecord/lib/active_record/result.rb b/activerecord/lib/active_record/result.rb index 3bd2b32263b45..adc1436b8cfba 100644 --- a/activerecord/lib/active_record/result.rb +++ b/activerecord/lib/active_record/result.rb @@ -163,7 +163,8 @@ def column_types @types_hash ||= begin types = {} @columns.each_with_index do |name, index| - types[name] = types[index] = @column_types[index] + type = @column_types[index] || Type.default_value + types[name] = types[index] = type end types.freeze end diff --git a/activerecord/test/cases/result_test.rb b/activerecord/test/cases/result_test.rb index e0216f53f19c5..90b54b48e986b 100644 --- a/activerecord/test/cases/result_test.rb +++ b/activerecord/test/cases/result_test.rb @@ -147,5 +147,21 @@ def result assert_equal a.rows, b.rows assert_equal a.column_indexes, b.column_indexes end + + test "column_types handles nil types in the column_types array" do + values = [["1.1", "2.2"], ["3.3", "4.4"]] + columns = ["col1", "col2"] + types = [Type::Integer.new, nil] # Deliberately nil type for col2 + result = Result.new(columns, values, types) + + assert_not_nil result.column_types["col1"] + assert_not_nil result.column_types["col2"] + + assert_instance_of ActiveRecord::Type::Value, result.column_types["col2"] + + assert_nothing_raised do + result.column_types["col2"].deserialize("test value") + end + end end end From 8653b8a532544da53339a0d1f282e6941aa0b97b Mon Sep 17 00:00:00 2001 From: zzak Date: Thu, 13 Mar 2025 09:59:57 +0900 Subject: [PATCH 0035/1075] Make assert_driver_capabilities flexible with unexpected keys ``` Failure: DriverTest#test_assert_driver_capabilities_ignores_unexpected_options [test/dispatch/system_testing/driver_test.rb:159]: --- expected +++ actual @@ -1 +1 @@ -{"goog:chromeOptions"=>{"args"=>["--disable-search-engine-choice-screen"]}, "browserName"=>"chrome"} +{"goog:chromeOptions"=>{"args"=>["--disable-search-engine-choice-screen"], "binary"=>"/usr/bin/chromium-browser"}, "browserName"=>"chrome"} ``` For example, with failing assertion: ``` Failure: DriverTest#test_assert_driver_capabilities_ignores_unexpected_options [test/dispatch/system_testing/driver_test.rb:160]: Expected goog:chromeOptions[binary] to be /usr/bin/chromium-browsers, got /usr/bin/chromium-browser. Expected: "/usr/bin/chromium-browsers" Actual: "/usr/bin/chromium-browser" ``` --- .../dispatch/system_testing/driver_test.rb | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/actionpack/test/dispatch/system_testing/driver_test.rb b/actionpack/test/dispatch/system_testing/driver_test.rb index fc79701026e71..93b2449f0007d 100644 --- a/actionpack/test/dispatch/system_testing/driver_test.rb +++ b/actionpack/test/dispatch/system_testing/driver_test.rb @@ -144,6 +144,21 @@ class DriverTest < ActiveSupport::TestCase assert_driver_capabilities driver, expected end + test "assert_driver_capabilities ignores unexpected options" do + driver = ActionDispatch::SystemTesting::Driver.new(:selenium, screen_size: [1400, 1400], using: :chrome) do |option| + option.binary = "/usr/bin/chromium-browser" + end + driver.use + + expected = { + "goog:chromeOptions" => { + "args" => ["--disable-search-engine-choice-screen"], + }, + "browserName" => "chrome" + } + assert_driver_capabilities driver, expected + end + test "does not define extra capabilities" do driver = ActionDispatch::SystemTesting::Driver.new(:selenium, screen_size: [1400, 1400], using: :firefox) @@ -202,6 +217,20 @@ class DriverTest < ActiveSupport::TestCase def assert_driver_capabilities(driver, expected_capabilities) capabilities = driver.__send__(:browser_options)[:options].as_json - assert_equal expected_capabilities, capabilities.slice(*expected_capabilities.keys) + expected_capabilities.each do |key, expected_value| + actual_value = capabilities[key] + + case expected_value + when Array + expected_value.each { |item| assert_includes actual_value, item, "Expected #{key} to include #{item}" } + when Hash + expected_value.each do |sub_key, sub_value| + real_value = actual_value&.dig(sub_key) + assert_equal sub_value, real_value, "Expected #{key}[#{sub_key}] to be #{sub_value}, got #{real_value}" + end + else + assert_equal expected_value, actual_value, "Expected #{key} to be #{expected_value}, got #{actual_value}" + end + end end end From 95fe907a1667a45e739874167c755ae9ffa85a3f Mon Sep 17 00:00:00 2001 From: Edouard CHIN Date: Thu, 13 Mar 2025 02:35:23 +0100 Subject: [PATCH 0036/1075] Remove method_added hook doing nothing: - ### Context In #54646, I fixed a bug. The patch introduced a behaviour change which was caught by sorbet typechecking. ------------------------ ```ruby class Current < ActiveSupport::Current def foo end end ``` Previously: ```ruby Current.method(:foo).source_location => nil ``` After: ```ruby Current.method(:foo).source_location => [lib/active_support/current_attributes.rb", 191] ``` -------------------- Basically, before, we were never creating a delegation method to the class, and instead relied on `method_missing` entirely. ### Problem There is no problem per se, but this method_added hook used to do nothing. And the previous patch now creates a delegation on the class. This is because when a method is added, `respond_to?` on the instance will always return `true` since we have just defined it. When calling `attribute :foo`, a method on the **singleton class** gets created, but doesn't not trigger our method_added hook defined on the **class**. --- activesupport/lib/active_support/current_attributes.rb | 8 -------- 1 file changed, 8 deletions(-) diff --git a/activesupport/lib/active_support/current_attributes.rb b/activesupport/lib/active_support/current_attributes.rb index 5415bfdc82a8c..38d98a817b977 100644 --- a/activesupport/lib/active_support/current_attributes.rb +++ b/activesupport/lib/active_support/current_attributes.rb @@ -182,14 +182,6 @@ def method_missing(name, ...) def respond_to_missing?(name, _) instance.respond_to?(name) || super end - - def method_added(name) - super - return if name == :initialize - return unless public_method_defined?(name) - return if singleton_class.method_defined?(name) || singleton_class.private_method_defined?(name) - Delegation.generate(singleton_class, [name], to: :instance, as: self, nilable: false) - end end class_attribute :defaults, instance_writer: false, default: {}.freeze From 2ed9bc1ec9c499e55334bf396b682f7924db5680 Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Thu, 13 Mar 2025 09:43:36 +0100 Subject: [PATCH 0037/1075] Add tests --- .../lib/active_support/current_attributes.rb | 21 +++++++++++-- activesupport/test/current_attributes_test.rb | 31 +++++++++++++++++++ 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/activesupport/lib/active_support/current_attributes.rb b/activesupport/lib/active_support/current_attributes.rb index 38d98a817b977..d85562bd38aaa 100644 --- a/activesupport/lib/active_support/current_attributes.rb +++ b/activesupport/lib/active_support/current_attributes.rb @@ -117,6 +117,9 @@ def attribute(*names, default: NOT_SET) raise ArgumentError, "Restricted attribute names: #{invalid_attribute_names.join(", ")}" end + Delegation.generate(singleton_class, names, to: :instance, nilable: false, signature: "") + Delegation.generate(singleton_class, names.map { |n| "#{n}=" }, to: :instance, nilable: false, signature: "value") + ActiveSupport::CodeGenerator.batch(generated_attribute_methods, __FILE__, __LINE__) do |owner| names.each do |name| owner.define_cached_method(name, namespace: :current_attributes) do |batch| @@ -134,9 +137,6 @@ def attribute(*names, default: NOT_SET) end end - Delegation.generate(singleton_class, names, to: :instance, nilable: false, signature: "") - Delegation.generate(singleton_class, names.map { |n| "#{n}=" }, to: :instance, nilable: false, signature: "value") - self.defaults = defaults.merge(names.index_with { default }) end @@ -182,6 +182,21 @@ def method_missing(name, ...) def respond_to_missing?(name, _) instance.respond_to?(name) || super end + + def method_added(name) + super + + # We try to generate instance delegators early to not rely on method_missing. + return if name == :initialize + + # If the added method isn't public, we don't delegate it. + return unless public_method_defined?(name) + + # If we already have a class method by that name, we don't override it. + return if singleton_class.method_defined?(name) || singleton_class.private_method_defined?(name) + + Delegation.generate(singleton_class, [name], to: :instance, as: self, nilable: false) + end end class_attribute :defaults, instance_writer: false, default: {}.freeze diff --git a/activesupport/test/current_attributes_test.rb b/activesupport/test/current_attributes_test.rb index 56d1c225e37a0..23bc26cc16795 100644 --- a/activesupport/test/current_attributes_test.rb +++ b/activesupport/test/current_attributes_test.rb @@ -276,4 +276,35 @@ def foo; end # Sets the cache because of a `method_added` hook assert_instance_of(Hash, current.bar) end + + test "instance delegators are eagerly defined" do + current = Class.new(ActiveSupport::CurrentAttributes) do + def self.name + "MyCurrent" + end + + def regular + :regular + end + + attribute :attr, default: :att + end + + assert current.singleton_class.method_defined?(:attr) + assert current.singleton_class.method_defined?(:attr=) + assert current.singleton_class.method_defined?(:regular) + end + + test "attribute delegators have precise signature" do + current = Class.new(ActiveSupport::CurrentAttributes) do + def self.name + "MyCurrent" + end + + attribute :attr, default: :att + end + + assert_equal [], current.method(:attr).parameters + assert_equal [[:req, :value]], current.method(:attr=).parameters + end end From 6204a55b07f6cdcbe6acd3447a36f0a7598e3a81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20Barri=C3=A9?= Date: Thu, 27 Feb 2025 11:14:24 +0100 Subject: [PATCH 0038/1075] Skip one allocation when there are no JSON options Co-authored-by: Jean Boussier --- activesupport/lib/active_support/json/encoding.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/activesupport/lib/active_support/json/encoding.rb b/activesupport/lib/active_support/json/encoding.rb index 550ae0137e8ee..55a181c2f1be1 100644 --- a/activesupport/lib/active_support/json/encoding.rb +++ b/activesupport/lib/active_support/json/encoding.rb @@ -38,7 +38,7 @@ class << self # ActiveSupport::JSON.encode({ key: "<>&" }, escape_html_entities: false) # # => "{\"key\":\"<>&\"}" def encode(value, options = nil) - if options.nil? + if options.nil? || options.empty? Encoding.encode_without_options(value) else Encoding.json_encoder.new(options).encode(value) From 8be58ff1e5519bf7a5772896896df0557f3d19e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20Barri=C3=A9?= Date: Thu, 27 Feb 2025 11:06:30 +0100 Subject: [PATCH 0039/1075] Add `escape: false` option to `ActiveSupport::JSON.encode` In many contexts you don't need the resuling JSON to be HTML-safe. This both saves a costly operation and renders cleaner JSON. Co-authored-by: Jean Boussier --- .../lib/active_support/json/encoding.rb | 16 +++++++++++++--- activesupport/test/json/encoding_test.rb | 5 +++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/activesupport/lib/active_support/json/encoding.rb b/activesupport/lib/active_support/json/encoding.rb index 55a181c2f1be1..da8c2e9964de8 100644 --- a/activesupport/lib/active_support/json/encoding.rb +++ b/activesupport/lib/active_support/json/encoding.rb @@ -20,8 +20,8 @@ class << self # ActiveSupport::JSON.encode({ team: 'rails', players: '36' }) # # => "{\"team\":\"rails\",\"players\":\"36\"}" # - # Generates JSON that is safe to include in JavaScript as it escapes - # U+2028 (Line Separator) and U+2029 (Paragraph Separator): + # By default, it generates JSON that is safe to include in JavaScript, as + # it escapes U+2028 (Line Separator) and U+2029 (Paragraph Separator): # # ActiveSupport::JSON.encode({ key: "\u2028" }) # # => "{\"key\":\"\\u2028\"}" @@ -32,11 +32,17 @@ class << self # ActiveSupport::JSON.encode({ key: "<>&" }) # # => "{\"key\":\"\\u003c\\u003e\\u0026\"}" # - # This can be changed with the +escape_html_entities+ option, or the + # This behavior can be changed with the +escape_html_entities+ option, or the # global escape_html_entities_in_json configuration option. # # ActiveSupport::JSON.encode({ key: "<>&" }, escape_html_entities: false) # # => "{\"key\":\"<>&\"}" + # + # For performance reasons, you can set the +escape+ option to false, + # which will skip all escaping: + # + # ActiveSupport::JSON.encode({ key: "\u2028<>&" }, escape: false) + # # => "{\"key\":\"\u2028<>&\"}" def encode(value, options = nil) if options.nil? || options.empty? Encoding.encode_without_options(value) @@ -76,6 +82,8 @@ def encode(value) end json = stringify(jsonify(value)) + return json unless @options.fetch(:escape, true) + # Rails does more escaping than the JSON gem natively does (we # escape \u2028 and \u2029 and optionally >, <, & to work around # certain browser problems). @@ -162,6 +170,8 @@ def encode(value) json = CODER.dump(value) + return json unless @options.fetch(:escape, true) + # Rails does more escaping than the JSON gem natively does (we # escape \u2028 and \u2029 and optionally >, <, & to work around # certain browser problems). diff --git a/activesupport/test/json/encoding_test.rb b/activesupport/test/json/encoding_test.rb index d4b4a8d8de095..ae21fc089c6ac 100644 --- a/activesupport/test/json/encoding_test.rb +++ b/activesupport/test/json/encoding_test.rb @@ -52,6 +52,11 @@ def test_hash_encoding assert_equal %({\"a\":\"b\",\"c\":\"d\"}), sorted_json(ActiveSupport::JSON.encode(a: :b, c: :d)) end + def test_unicode_escape + assert_equal %{{"\\u2028":"\\u2029"}}, ActiveSupport::JSON.encode("\u2028" => "\u2029") + assert_equal %{{"\u2028":"\u2029"}}, ActiveSupport::JSON.encode({ "\u2028" => "\u2029" }, escape: false) + end + def test_hash_keys_encoding ActiveSupport.escape_html_entities_in_json = true assert_equal "{\"\\u003c\\u003e\":\"\\u003c\\u003e\"}", ActiveSupport::JSON.encode("<>" => "<>") From 90616277e3d8fc46c9cf35d6a7470ff1ea0092f7 Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Thu, 13 Mar 2025 10:52:03 +0100 Subject: [PATCH 0040/1075] Skip JSON escaping when writing into JSON columns This extra escaping is only needed if the JSON end up included in a ` + ``` Using the asset pipeline: @@ -1064,7 +1084,7 @@ directly from the client to the cloud. ActiveStorage.start() ``` -2. Add `direct_upload: true` to your [file field](form_helpers.html#uploading-files): +2. Annotate file inputs with the direct upload URL using Rails' [file field helper](form_helpers.html#uploading-files). ```erb <%= form.file_field :attachments, multiple: true, direct_upload: true %> From 5aee189b72bed95c8df566013a1c0da583b64384 Mon Sep 17 00:00:00 2001 From: Yasuo Honda Date: Thu, 17 Apr 2025 08:40:13 +0900 Subject: [PATCH 0110/1075] Update devcontainer to use ruby version 3.4.3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ruby version installed with this commit: ``` vscode ➜ /workspaces/rails/activerecord (ruby343_devcontainer) $ ruby -v ruby 3.4.3 (2025-04-14 revision d0b7e5b6a0) +PRISM [aarch64-linux] vscode ➜ /workspaces/rails/activerecord (ruby343_devcontainer) $ ``` Ruby 3.4.3 image is available at: https://github.com/rails/devcontainer/pkgs/container/devcontainer%2Fimages%2Fruby/395452686?tag=3.4.3 --- .devcontainer/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 3802125d237b6..2c3d4d5497288 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,7 +1,7 @@ # See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.191.1/containers/ruby/.devcontainer/base.Dockerfile # [Choice] Ruby version: 3.4, 3.3, 3.2 -ARG VARIANT="3.4.2" +ARG VARIANT="3.4.3" FROM ghcr.io/rails/devcontainer/images/ruby:${VARIANT} RUN sudo apt-get update && export DEBIAN_FRONTEND=noninteractive \ From fe5c76846ccc19d19eafe2e41b11e04850146e69 Mon Sep 17 00:00:00 2001 From: bubiche Date: Thu, 17 Apr 2025 00:21:37 +0700 Subject: [PATCH 0111/1075] Action Cable: Allow setting nil as subscription connection identifier for Redis --- actioncable/CHANGELOG.md | 3 +++ .../lib/action_cable/subscription_adapter/base.rb | 3 ++- actioncable/test/subscription_adapter/redis_test.rb | 10 ++++++++++ 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/actioncable/CHANGELOG.md b/actioncable/CHANGELOG.md index ac140c3da7632..e6cd9e82e5eb6 100644 --- a/actioncable/CHANGELOG.md +++ b/actioncable/CHANGELOG.md @@ -1,2 +1,5 @@ +* Allow setting nil as subscription connection identifier for Redis. + + *Nguyen Nguyen* Please check [8-0-stable](https://github.com/rails/rails/blob/8-0-stable/actioncable/CHANGELOG.md) for previous changes. diff --git a/actioncable/lib/action_cable/subscription_adapter/base.rb b/actioncable/lib/action_cable/subscription_adapter/base.rb index 7de9fc2a88829..2df2667b2336c 100644 --- a/actioncable/lib/action_cable/subscription_adapter/base.rb +++ b/actioncable/lib/action_cable/subscription_adapter/base.rb @@ -29,7 +29,8 @@ def shutdown end def identifier - @server.config.cable[:id] ||= "ActionCable-PID-#{$$}" + @server.config.cable[:id] = "ActionCable-PID-#{$$}" unless @server.config.cable.key?(:id) + @server.config.cable[:id] end end end diff --git a/actioncable/test/subscription_adapter/redis_test.rb b/actioncable/test/subscription_adapter/redis_test.rb index dcc9ecab57b20..e90e4129ce89e 100644 --- a/actioncable/test/subscription_adapter/redis_test.rb +++ b/actioncable/test/subscription_adapter/redis_test.rb @@ -113,6 +113,16 @@ def connection_id end end +class RedisAdapterTest::ConnectorCustomIDNil < RedisAdapterTest::ConnectorDefaultID + def cable_config + super.merge(id: connection_id) + end + + def connection_id + nil + end +end + class RedisAdapterTest::ConnectorWithExcluded < RedisAdapterTest::ConnectorDefaultID def cable_config super.merge(adapter: "redis", channel_prefix: "custom") From 0430400f84a3efe09f794b541e0b894dc6c4618a Mon Sep 17 00:00:00 2001 From: Keegankb93 Date: Thu, 17 Apr 2025 00:57:33 -0500 Subject: [PATCH 0112/1075] Fix dropdown not opening - Removes *potentially* unnecessary js from the code - Closes a div in the index file --- guides/assets/javascripts/guides.js | 7 ------- guides/source/layout.html.erb | 3 ++- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/guides/assets/javascripts/guides.js b/guides/assets/javascripts/guides.js index e06ae3336f4dc..a31cbaeb010d2 100644 --- a/guides/assets/javascripts/guides.js +++ b/guides/assets/javascripts/guides.js @@ -79,13 +79,6 @@ // pressing escape, which is the standard key to collapse expanded elements. var guidesMenuButton = document.getElementById("guides-menu-button"); - // The link is now acting as a button (but still allows for open in new tab). - guidesMenuButton.setAttribute('role', 'button') - guidesMenuButton.setAttribute('aria-controls', guidesMenuButton.getAttribute('data-aria-controls')); - guidesMenuButton.setAttribute('aria-expanded', guidesMenuButton.getAttribute('data-aria-expanded')); - guidesMenuButton.removeAttribute('data-aria-controls'); - guidesMenuButton.removeAttribute('data-aria-expanded'); - var guides = document.getElementById( guidesMenuButton.getAttribute("aria-controls") ); diff --git a/guides/source/layout.html.erb b/guides/source/layout.html.erb index 8e4849a1be223..0a6540b82d4de 100644 --- a/guides/source/layout.html.erb +++ b/guides/source/layout.html.erb @@ -78,7 +78,7 @@
From e0d14918643cb9121f33aeb49c8cdb1445c7252e Mon Sep 17 00:00:00 2001 From: Hartley McGuire Date: Fri, 18 Apr 2025 12:03:33 -0400 Subject: [PATCH 0113/1075] Don't recalculate GROUP BY in {update,delete}_all These values are already calculated when building the SelectManager arel, so we can use them instead of recalculating/rebuilding them. Specifically, this prevents calling `arel_columns` after building the relation, which opens up the possibility to cache the built Arel ast. It couldn't be cached before because `arel_columns` may modify the relation. Going through the {Update,Delete}Manager's ast in `Crud` is necessary to prevent `Group(Group(SQL))` because `#group` will wrap its arguments in `Group` nodes. --- activerecord/lib/active_record/relation.rb | 6 ++---- activerecord/lib/arel/crud.rb | 9 ++++----- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/activerecord/lib/active_record/relation.rb b/activerecord/lib/active_record/relation.rb index f1dc9d96a6d21..1503033849953 100644 --- a/activerecord/lib/active_record/relation.rb +++ b/activerecord/lib/active_record/relation.rb @@ -628,14 +628,13 @@ def update_all(updates) arel = eager_loading? ? apply_join_dependency.arel : build_arel(c) arel.source.left = table - group_values_arel_columns = arel_columns(group_values.uniq) having_clause_ast = having_clause.ast unless having_clause.empty? key = if model.composite_primary_key? primary_key.map { |pk| table[pk] } else table[primary_key] end - stmt = arel.compile_update(values, key, having_clause_ast, group_values_arel_columns) + stmt = arel.compile_update(values, key, having_clause_ast) c.update(stmt, "#{model} Update All").tap { reset } end end @@ -1045,14 +1044,13 @@ def delete_all arel = eager_loading? ? apply_join_dependency.arel : build_arel(c) arel.source.left = table - group_values_arel_columns = arel_columns(group_values.uniq) having_clause_ast = having_clause.ast unless having_clause.empty? key = if model.composite_primary_key? primary_key.map { |pk| table[pk] } else table[primary_key] end - stmt = arel.compile_delete(key, having_clause_ast, group_values_arel_columns) + stmt = arel.compile_delete(key, having_clause_ast) c.delete(stmt, "#{model} Delete All").tap { reset } end diff --git a/activerecord/lib/arel/crud.rb b/activerecord/lib/arel/crud.rb index 18240c8bee7ae..80ac383c130b8 100644 --- a/activerecord/lib/arel/crud.rb +++ b/activerecord/lib/arel/crud.rb @@ -17,8 +17,7 @@ def create_insert def compile_update( values, key = nil, - having_clause = nil, - group_values_columns = [] + having_clause = nil ) um = UpdateManager.new(source) um.set(values) @@ -28,19 +27,19 @@ def compile_update( um.wheres = constraints um.key = key - um.group(group_values_columns) unless group_values_columns.empty? + um.ast.groups = @ctx.groups um.having(having_clause) unless having_clause.nil? um end - def compile_delete(key = nil, having_clause = nil, group_values_columns = []) + def compile_delete(key = nil, having_clause = nil) dm = DeleteManager.new(source) dm.take(limit) dm.offset(offset) dm.order(*orders) dm.wheres = constraints dm.key = key - dm.group(group_values_columns) unless group_values_columns.empty? + dm.ast.groups = @ctx.groups dm.having(having_clause) unless having_clause.nil? dm end From 019f16239aa8000a700ddaac4dff89da091605a2 Mon Sep 17 00:00:00 2001 From: Hartley McGuire Date: Fri, 18 Apr 2025 13:20:10 -0400 Subject: [PATCH 0114/1075] Don't recalculate HAVING in {update,delete}_all These values are already calculated when building the SelectManager arel, so we can use theminstead of recalculating/rebuilding them. --- activerecord/lib/active_record/relation.rb | 6 ++---- activerecord/lib/arel/crud.rb | 12 ++++-------- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/activerecord/lib/active_record/relation.rb b/activerecord/lib/active_record/relation.rb index 1503033849953..f006ec1a2c62c 100644 --- a/activerecord/lib/active_record/relation.rb +++ b/activerecord/lib/active_record/relation.rb @@ -628,13 +628,12 @@ def update_all(updates) arel = eager_loading? ? apply_join_dependency.arel : build_arel(c) arel.source.left = table - having_clause_ast = having_clause.ast unless having_clause.empty? key = if model.composite_primary_key? primary_key.map { |pk| table[pk] } else table[primary_key] end - stmt = arel.compile_update(values, key, having_clause_ast) + stmt = arel.compile_update(values, key) c.update(stmt, "#{model} Update All").tap { reset } end end @@ -1044,13 +1043,12 @@ def delete_all arel = eager_loading? ? apply_join_dependency.arel : build_arel(c) arel.source.left = table - having_clause_ast = having_clause.ast unless having_clause.empty? key = if model.composite_primary_key? primary_key.map { |pk| table[pk] } else table[primary_key] end - stmt = arel.compile_delete(key, having_clause_ast) + stmt = arel.compile_delete(key) c.delete(stmt, "#{model} Delete All").tap { reset } end diff --git a/activerecord/lib/arel/crud.rb b/activerecord/lib/arel/crud.rb index 80ac383c130b8..f4d60b775e781 100644 --- a/activerecord/lib/arel/crud.rb +++ b/activerecord/lib/arel/crud.rb @@ -14,11 +14,7 @@ def create_insert InsertManager.new end - def compile_update( - values, - key = nil, - having_clause = nil - ) + def compile_update(values, key = nil) um = UpdateManager.new(source) um.set(values) um.take(limit) @@ -28,11 +24,11 @@ def compile_update( um.key = key um.ast.groups = @ctx.groups - um.having(having_clause) unless having_clause.nil? + @ctx.havings.each { |h| um.having(h) } um end - def compile_delete(key = nil, having_clause = nil) + def compile_delete(key = nil) dm = DeleteManager.new(source) dm.take(limit) dm.offset(offset) @@ -40,7 +36,7 @@ def compile_delete(key = nil, having_clause = nil) dm.wheres = constraints dm.key = key dm.ast.groups = @ctx.groups - dm.having(having_clause) unless having_clause.nil? + @ctx.havings.each { |h| dm.having(h) } dm end end From 6ff9e82e4774d494298d6bfca2ceebe92aec64a1 Mon Sep 17 00:00:00 2001 From: Hartley McGuire Date: Fri, 18 Apr 2025 13:23:44 -0400 Subject: [PATCH 0115/1075] Pass existing connection to #arel in #to_sql This prevents having to do another connection lookup when manually generating SQL for a Relation. Additionally, by adding a connection parameter to `#arel` the `{update,delete}_all` methods can now go through `#arel` instead of `#build_arel`. This prevents having to rebuild the Arel AST in cases where a relation is re-used for querying after calling `{update,delete}_all`. --- .../associations/join_dependency/join_association.rb | 2 +- activerecord/lib/active_record/relation.rb | 6 +++--- activerecord/lib/active_record/relation/query_methods.rb | 8 ++++++-- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/activerecord/lib/active_record/associations/join_dependency/join_association.rb b/activerecord/lib/active_record/associations/join_dependency/join_association.rb index 9102b6ac97665..7f9e075c9168c 100644 --- a/activerecord/lib/active_record/associations/join_dependency/join_association.rb +++ b/activerecord/lib/active_record/associations/join_dependency/join_association.rb @@ -53,7 +53,7 @@ def join_constraints(foreign_table, foreign_klass, join_type, alias_tracker) end end - arel = scope.arel(alias_tracker.aliases) + arel = scope.arel(aliases: alias_tracker.aliases) nodes = arel.constraints.first if nodes.is_a?(Arel::Nodes::And) diff --git a/activerecord/lib/active_record/relation.rb b/activerecord/lib/active_record/relation.rb index f006ec1a2c62c..312f4c130ecd7 100644 --- a/activerecord/lib/active_record/relation.rb +++ b/activerecord/lib/active_record/relation.rb @@ -625,7 +625,7 @@ def update_all(updates) end model.with_connection do |c| - arel = eager_loading? ? apply_join_dependency.arel : build_arel(c) + arel = eager_loading? ? apply_join_dependency.arel : arel(c) arel.source.left = table key = if model.composite_primary_key? @@ -1040,7 +1040,7 @@ def delete_all end model.with_connection do |c| - arel = eager_loading? ? apply_join_dependency.arel : build_arel(c) + arel = eager_loading? ? apply_join_dependency.arel : arel(c) arel.source.left = table key = if model.composite_primary_key? @@ -1233,7 +1233,7 @@ def to_sql end else model.with_connection do |conn| - conn.unprepared_statement { conn.to_sql(arel) } + conn.unprepared_statement { conn.to_sql(arel(conn)) } end end end diff --git a/activerecord/lib/active_record/relation/query_methods.rb b/activerecord/lib/active_record/relation/query_methods.rb index bd1f173cc032a..29bfb6cdf2792 100644 --- a/activerecord/lib/active_record/relation/query_methods.rb +++ b/activerecord/lib/active_record/relation/query_methods.rb @@ -1591,8 +1591,12 @@ def excluding!(records) # :nodoc: end # Returns the Arel object associated with the relation. - def arel(aliases = nil) # :nodoc: - @arel ||= with_connection { |c| build_arel(c, aliases) } + def arel(conn = nil, aliases: nil) # :nodoc: + @arel ||= if conn + build_arel(conn, aliases) + else + with_connection { |c| build_arel(c, aliases) } + end end def construct_join_dependency(associations, join_type) # :nodoc: From 8b00d0985f88a3f151dd21e8dfdaf6d1c2f497b0 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sat, 19 Apr 2025 09:42:36 +0200 Subject: [PATCH 0116/1075] Add assert_in_body/assert_not_in_body (#54938) * Add assert_in_body/assert_not_in_body As a simple encapsulation of checking a response body for a piece of text without having to go through a heavy-duty DOM operation. * Add CHANGELOG entry * Appease Rubocop * Escape text so it doesnt trigger regular expressions inadvertedly --- actionpack/CHANGELOG.md | 4 ++++ .../action_dispatch/testing/assertions/response.rb | 14 ++++++++++++++ .../test/controller/action_pack_assertions_test.rb | 6 ++++++ 3 files changed, 24 insertions(+) diff --git a/actionpack/CHANGELOG.md b/actionpack/CHANGELOG.md index 6bef5545d0330..1b5fb8e2f6358 100644 --- a/actionpack/CHANGELOG.md +++ b/actionpack/CHANGELOG.md @@ -1,3 +1,7 @@ +* Add assert_in_body/assert_not_in_body as the simplest way to check if a piece of text is in the response body. + + *DHH* + * Include cookie name when calculating maximum allowed size. *Hartley McGuire* diff --git a/actionpack/lib/action_dispatch/testing/assertions/response.rb b/actionpack/lib/action_dispatch/testing/assertions/response.rb index d7598b7cf7075..816d5f6192728 100644 --- a/actionpack/lib/action_dispatch/testing/assertions/response.rb +++ b/actionpack/lib/action_dispatch/testing/assertions/response.rb @@ -71,6 +71,20 @@ def assert_redirected_to(url_options = {}, options = {}, message = nil) assert_operator redirect_expected, :===, redirect_is, message end + # Asserts that the given +text+ is present somewhere in the response body. + # + # assert_in_body fixture(:name).description + def assert_in_body(text) + assert_match(/#{Regexp.escape(text)}/, @response.body) + end + + # Asserts that the given +text+ is not present anywhere in the response body. + # + # assert_not_in_body fixture(:name).description + def assert_not_in_body(text) + assert_no_match(/#{Regexp.escape(text)}/, @response.body) + end + private # Proxy to to_param if the object will respond to it. def parameterize(value) diff --git a/actionpack/test/controller/action_pack_assertions_test.rb b/actionpack/test/controller/action_pack_assertions_test.rb index aa44b36d9e1bc..46306d5a66c22 100644 --- a/actionpack/test/controller/action_pack_assertions_test.rb +++ b/actionpack/test/controller/action_pack_assertions_test.rb @@ -496,6 +496,12 @@ def test_assert_response_failure_response_with_no_exception assert_response 500 assert_equal "Boom", response.body end + + def test_assert_in_body + post :raise_exception_on_get + assert_in_body "request method: POST" + assert_not_in_body "request method: GET" + end end class ActionPackHeaderTest < ActionController::TestCase From b575d933be590d0486f8e9617e7a590c2d3f773d Mon Sep 17 00:00:00 2001 From: zzak Date: Tue, 25 Feb 2025 21:37:04 +0900 Subject: [PATCH 0117/1075] Ensure all railties tests require strict_warnings --- railties/Rakefile | 1 + railties/test/abstract_unit.rb | 2 -- railties/test/configuration/middleware_stack_proxy_test.rb | 1 - railties/test/generators/argv_scrubber_test.rb | 1 - railties/test/generators/generator_test.rb | 1 - railties/test/isolation/abstract_unit.rb | 1 - 6 files changed, 1 insertion(+), 6 deletions(-) diff --git a/railties/Rakefile b/railties/Rakefile index f5fd82c5ed1d4..a54021e9b50c7 100644 --- a/railties/Rakefile +++ b/railties/Rakefile @@ -116,6 +116,7 @@ namespace :test do ARGV.clear.concat test_options Rake.application = nil + require "../tools/strict_warnings" load file } else diff --git a/railties/test/abstract_unit.rb b/railties/test/abstract_unit.rb index 815785a387d0c..d61495abe6dd6 100644 --- a/railties/test/abstract_unit.rb +++ b/railties/test/abstract_unit.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require_relative "../../tools/strict_warnings" - ENV["RAILS_ENV"] ||= "test" require "stringio" diff --git a/railties/test/configuration/middleware_stack_proxy_test.rb b/railties/test/configuration/middleware_stack_proxy_test.rb index adae453d092c6..20e8c0c8ecb0a 100644 --- a/railties/test/configuration/middleware_stack_proxy_test.rb +++ b/railties/test/configuration/middleware_stack_proxy_test.rb @@ -1,6 +1,5 @@ # frozen_string_literal: true -require_relative "../../../tools/strict_warnings" require "active_support" require "active_support/testing/autorun" require "rails/configuration" diff --git a/railties/test/generators/argv_scrubber_test.rb b/railties/test/generators/argv_scrubber_test.rb index af77b04cb6e95..461b4f46ecc43 100644 --- a/railties/test/generators/argv_scrubber_test.rb +++ b/railties/test/generators/argv_scrubber_test.rb @@ -1,6 +1,5 @@ # frozen_string_literal: true -require_relative "../../../tools/strict_warnings" require "active_support/test_case" require "active_support/testing/autorun" require "rails/generators/rails/app/app_generator" diff --git a/railties/test/generators/generator_test.rb b/railties/test/generators/generator_test.rb index 94b9b0b86e90e..281d5a5572a1d 100644 --- a/railties/test/generators/generator_test.rb +++ b/railties/test/generators/generator_test.rb @@ -1,6 +1,5 @@ # frozen_string_literal: true -require_relative "../../../tools/strict_warnings" require "active_support/test_case" require "active_support/testing/autorun" require "rails/generators/app_base" diff --git a/railties/test/isolation/abstract_unit.rb b/railties/test/isolation/abstract_unit.rb index ca86e4b8d7533..48065f38890e0 100644 --- a/railties/test/isolation/abstract_unit.rb +++ b/railties/test/isolation/abstract_unit.rb @@ -8,7 +8,6 @@ # # It is also good to know what is the bare minimum to get # Rails booted up. -require_relative "../../../tools/strict_warnings" require "fileutils" require "shellwords" From 83b2c77cd9381e911208981f18dad7c511014307 Mon Sep 17 00:00:00 2001 From: zzak Date: Wed, 26 Feb 2025 08:44:33 +0900 Subject: [PATCH 0118/1075] Assume some warnings might not have the full PROJECT_ROOT In the case of minitest assert_nil: > Use assert_nil if expecting nil from test/application/configuration_test.rb:4031 We can cheat and expect any warning that has a path which includes "test/*.rb" should exist before raising in CI. If the message doesn't contain a path like that, and is not included in the PROJECT_ROOT then we can ignore it. --- tools/strict_warnings.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tools/strict_warnings.rb b/tools/strict_warnings.rb index 9ada8732c0e33..6776e532b7841 100644 --- a/tools/strict_warnings.rb +++ b/tools/strict_warnings.rb @@ -30,7 +30,8 @@ def warn(message, ...) super - return unless message.include?(PROJECT_ROOT) + testpath = message[/test\/.*\.rb/]&.chomp || message + return unless message.include?(PROJECT_ROOT) || Pathname.new(testpath).exist? return if ALLOWED_WARNINGS.match?(message) return unless ENV["RAILS_STRICT_WARNINGS"] || ENV["BUILDKITE"] From e9ee790d694298b8a48d44cd089934e6c8bc7597 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 21 Apr 2025 15:53:45 +0200 Subject: [PATCH 0119/1075] Add --reset option to bin/setup (#54952) Makes it easier to zero out a database and load seeds during development. --- railties/CHANGELOG.md | 4 ++++ .../lib/rails/generators/rails/app/templates/bin/setup.tt | 1 + 2 files changed, 5 insertions(+) diff --git a/railties/CHANGELOG.md b/railties/CHANGELOG.md index edf0b356d341b..91b5bddacb4c0 100644 --- a/railties/CHANGELOG.md +++ b/railties/CHANGELOG.md @@ -1,3 +1,7 @@ +* Add --reset option to bin/setup which will call db:reset as part of the setup. + + *DHH* + * Add RuboCop cache restoration to RuboCop job in GitHub Actions workflow templates. *Lovro Bikić* diff --git a/railties/lib/rails/generators/rails/app/templates/bin/setup.tt b/railties/lib/rails/generators/rails/app/templates/bin/setup.tt index 223dc69272a9c..1539b936d80ba 100644 --- a/railties/lib/rails/generators/rails/app/templates/bin/setup.tt +++ b/railties/lib/rails/generators/rails/app/templates/bin/setup.tt @@ -31,6 +31,7 @@ FileUtils.chdir APP_ROOT do puts "\n== Preparing database ==" system! "bin/rails db:prepare" + system! "bin/rails db:reset" if ARGV.include?("--reset") <% end -%> puts "\n== Removing old logs and tempfiles ==" From 084a1fd9b9aedc494b40869d24513082e15b77db Mon Sep 17 00:00:00 2001 From: Juanito Fatas Date: Tue, 22 Apr 2025 07:47:01 +0900 Subject: [PATCH 0120/1075] Fix broken weblog.rubyonrails.org links in Guides [ci skip] They now moved to https://rubyonrails.org/ without redirect --- guides/source/2_2_release_notes.md | 4 ++-- guides/source/2_3_release_notes.md | 8 ++++---- guides/source/3_0_release_notes.md | 4 ++-- guides/source/upgrading_ruby_on_rails.md | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/guides/source/2_2_release_notes.md b/guides/source/2_2_release_notes.md index 3c34f486138ff..0a7c8a220f2b2 100644 --- a/guides/source/2_2_release_notes.md +++ b/guides/source/2_2_release_notes.md @@ -60,7 +60,7 @@ This will put the guides inside `Rails.root/doc/guides` and you may start surfin * Major contributions from [Xavier Noria](http://advogato.org/person/fxn/diary.html) and [Hongli Lai](http://izumi.plan99.net/blog/). * More information: * [Rails Guides hackfest](http://hackfest.rubyonrails.org/guide) - * [Help improve Rails documentation on Git branch](https://weblog.rubyonrails.org/2008/5/2/help-improve-rails-documentation-on-git-branch) + * [Help improve Rails documentation on Git branch](https://rubyonrails.org/2008/5/2/help-improve-rails-documentation-on-git-branch) Better integration with HTTP : Out of the box ETag support ---------------------------------------------------------- @@ -112,7 +112,7 @@ config.threadsafe! * More information : * [Thread safety for your Rails](http://m.onkey.org/2008/10/23/thread-safety-for-your-rails) - * [Thread safety project announcement](https://weblog.rubyonrails.org/2008/8/16/josh-peek-officially-joins-the-rails-core) + * [Thread safety project announcement](https://rubyonrails.org/2008/8/16/josh-peek-officially-joins-the-rails-core) * [Q/A: What Thread-safe Rails Means](http://blog.headius.com/2008/08/qa-what-thread-safe-rails-means.html) Active Record diff --git a/guides/source/2_3_release_notes.md b/guides/source/2_3_release_notes.md index 490292e5472c5..7c2668d764fd0 100644 --- a/guides/source/2_3_release_notes.md +++ b/guides/source/2_3_release_notes.md @@ -54,7 +54,7 @@ Documentation The [Ruby on Rails guides](https://guides.rubyonrails.org/) project has published several additional guides for Rails 2.3. In addition, a [separate site](https://edgeguides.rubyonrails.org/) maintains updated copies of the Guides for Edge Rails. Other documentation efforts include a relaunch of the [Rails wiki](http://newwiki.rubyonrails.org/) and early planning for a Rails Book. -* More Information: [Rails Documentation Projects](https://weblog.rubyonrails.org/2009/1/15/rails-documentation-projects) +* More Information: [Rails Documentation Projects](https://rubyonrails.org/2009/1/15/rails-documentation-projects) Ruby 1.9.1 Support ------------------ @@ -89,7 +89,7 @@ accepts_nested_attributes_for :author, ``` * Lead Contributor: [Eloy Duran](http://superalloy.nl/) -* More Information: [Nested Model Forms](https://weblog.rubyonrails.org/2009/1/26/nested-model-forms) +* More Information: [Nested Model Forms](https://rubyonrails.org/2009/1/26/nested-model-forms) ### Nested Transactions @@ -377,7 +377,7 @@ You can write this view in Rails 2.3: * Lead Contributor: [Eloy Duran](http://superalloy.nl/) * More Information: - * [Nested Model Forms](https://weblog.rubyonrails.org/2009/1/26/nested-model-forms) + * [Nested Model Forms](https://rubyonrails.org/2009/1/26/nested-model-forms) * [complex-form-examples](https://github.com/alloy/complex-form-examples) * [What's New in Edge Rails: Nested Object Forms](http://archives.ryandaigle.com/articles/2009/2/1/what-s-new-in-edge-rails-nested-attributes) @@ -552,7 +552,7 @@ In addition to the Rack changes covered above, Railties (the core code of Rails Rails Metal is a new mechanism that provides superfast endpoints inside of your Rails applications. Metal classes bypass routing and Action Controller to give you raw speed (at the cost of all the things in Action Controller, of course). This builds on all of the recent foundation work to make Rails a Rack application with an exposed middleware stack. Metal endpoints can be loaded from your application or from plugins. * More Information: - * [Introducing Rails Metal](https://weblog.rubyonrails.org/2008/12/17/introducing-rails-metal) + * [Introducing Rails Metal](https://rubyonrails.org/2008/12/17/introducing-rails-metal) * [Rails Metal: a micro-framework with the power of Rails](http://soylentfoo.jnewland.com/articles/2008/12/16/rails-metal-a-micro-framework-with-the-power-of-rails-m) * [Metal: Super-fast Endpoints within your Rails Apps](http://www.railsinside.com/deployment/180-metal-super-fast-endpoints-within-your-rails-apps.html) * [What's New in Edge Rails: Rails Metal](http://archives.ryandaigle.com/articles/2008/12/18/what-s-new-in-edge-rails-rails-metal) diff --git a/guides/source/3_0_release_notes.md b/guides/source/3_0_release_notes.md index 422d59d26e226..172ce8ee004f9 100644 --- a/guides/source/3_0_release_notes.md +++ b/guides/source/3_0_release_notes.md @@ -155,7 +155,7 @@ Documentation The documentation in the Rails tree is being updated with all the API changes, additionally, the [Rails Edge Guides](https://edgeguides.rubyonrails.org/) are being updated one by one to reflect the changes in Rails 3.0. The guides at [guides.rubyonrails.org](https://guides.rubyonrails.org/) however will continue to contain only the stable version of Rails (at this point, version 2.3.5, until 3.0 is released). -More Information: - [Rails Documentation Projects](https://weblog.rubyonrails.org/2009/1/15/rails-documentation-projects) +More Information: - [Rails Documentation Projects](https://rubyonrails.org/2009/1/15/rails-documentation-projects) Internationalization @@ -250,7 +250,7 @@ Deprecations: More Information: * [Render Options in Rails 3](https://blog.engineyard.com/2010/render-options-in-rails-3) -* [Three reasons to love ActionController::Responder](https://weblog.rubyonrails.org/2009/8/31/three-reasons-love-responder) +* [Three reasons to love ActionController::Responder](https://rubyonrails.org/2009/8/31/three-reasons-love-responder) ### Action Dispatch diff --git a/guides/source/upgrading_ruby_on_rails.md b/guides/source/upgrading_ruby_on_rails.md index 353fffd581877..5d1e65a8eb975 100644 --- a/guides/source/upgrading_ruby_on_rails.md +++ b/guides/source/upgrading_ruby_on_rails.md @@ -2483,7 +2483,7 @@ being used, you can update your form to use the `PUT` method instead: <%= form_for [ :update_name, @user ], method: :put do |f| %> ``` -For more on PATCH and why this change was made, see [this post](https://weblog.rubyonrails.org/2012/2/26/edge-rails-patch-is-the-new-primary-http-method-for-updates/) +For more on PATCH and why this change was made, see [this post](https://rubyonrails.org/2012/2/26/edge-rails-patch-is-the-new-primary-http-method-for-updates/) on the Rails blog. #### A note about media types From 405c2dd973c07a890e91bc9836a175583be737bc Mon Sep 17 00:00:00 2001 From: Ryuta Kamizono Date: Tue, 22 Apr 2025 17:04:29 +0900 Subject: [PATCH 0121/1075] Fix a link with trailing slash that does not work [ci-skip] --- guides/source/upgrading_ruby_on_rails.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/guides/source/upgrading_ruby_on_rails.md b/guides/source/upgrading_ruby_on_rails.md index 5d1e65a8eb975..153cf1b1312ab 100644 --- a/guides/source/upgrading_ruby_on_rails.md +++ b/guides/source/upgrading_ruby_on_rails.md @@ -2483,7 +2483,7 @@ being used, you can update your form to use the `PUT` method instead: <%= form_for [ :update_name, @user ], method: :put do |f| %> ``` -For more on PATCH and why this change was made, see [this post](https://rubyonrails.org/2012/2/26/edge-rails-patch-is-the-new-primary-http-method-for-updates/) +For more on PATCH and why this change was made, see [this post](https://rubyonrails.org/2012/2/26/edge-rails-patch-is-the-new-primary-http-method-for-updates) on the Rails blog. #### A note about media types From db33d20298b63046efd1d51d275d6f50598e1336 Mon Sep 17 00:00:00 2001 From: Andrey Sobolev Date: Wed, 23 Apr 2025 14:00:33 +0300 Subject: [PATCH 0122/1075] Fix link to docs in Composite Primary Keys guide --- guides/source/active_record_composite_primary_keys.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/guides/source/active_record_composite_primary_keys.md b/guides/source/active_record_composite_primary_keys.md index 859d8a4d760f1..61704e7f71aa8 100644 --- a/guides/source/active_record_composite_primary_keys.md +++ b/guides/source/active_record_composite_primary_keys.md @@ -118,7 +118,7 @@ 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 +[Active Record Querying]: active_record_querying.html#conditions-with-id Associations between Models with Composite Primary Keys ------------------------------------------------------- From 3f0d13d6eea08766e74f429f6cea63a687061d3c Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Wed, 23 Apr 2025 09:50:28 -0400 Subject: [PATCH 0123/1075] Add a load hook for `ActiveRecord::DatabaseConfigurations` This can be used to register a db config handler from a railtie initializer before database tasks are defined. --- activerecord/CHANGELOG.md | 4 ++++ .../lib/active_record/database_configurations.rb | 10 +++++++--- guides/source/configuring.md | 1 + 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/activerecord/CHANGELOG.md b/activerecord/CHANGELOG.md index 706630cfa6a56..84da22fef1a10 100644 --- a/activerecord/CHANGELOG.md +++ b/activerecord/CHANGELOG.md @@ -1,3 +1,7 @@ +* Add a load hook `active_record_database_configurations` for `ActiveRecord::DatabaseConfigurations` + + *Mike Dalessio* + * Use `TRUE` and `FALSE` for SQLite queries with boolean columns. *Hartley McGuire* diff --git a/activerecord/lib/active_record/database_configurations.rb b/activerecord/lib/active_record/database_configurations.rb index 906f7873cbb66..b3054b71c79df 100644 --- a/activerecord/lib/active_record/database_configurations.rb +++ b/activerecord/lib/active_record/database_configurations.rb @@ -36,9 +36,11 @@ class InvalidConfigurationError < StandardError; end # to respond to `sharded?`. To implement this define the following in an # initializer: # - # ActiveRecord::DatabaseConfigurations.register_db_config_handler do |env_name, name, url, config| - # next unless config.key?(:vitess) - # VitessConfig.new(env_name, name, config) + # ActiveSupport.on_load(:active_record_database_configurations) do + # ActiveRecord::DatabaseConfigurations.register_db_config_handler do |env_name, name, url, config| + # next unless config.key?(:vitess) + # VitessConfig.new(env_name, name, config) + # end # end # # Note: applications must handle the condition in which custom config should be @@ -306,4 +308,6 @@ def environment_value_for(name) url end end + + ActiveSupport.run_load_hooks(:active_record_database_configurations, DatabaseConfigurations) end diff --git a/guides/source/configuring.md b/guides/source/configuring.md index 2959f92125a59..009181ac14972 100644 --- a/guides/source/configuring.md +++ b/guides/source/configuring.md @@ -3820,6 +3820,7 @@ These are the load hooks you can use in your own code. To hook into the initiali | `ActiveModel::Model` | `active_model` | | `ActiveModel::Translation` | `active_model_translation` | | `ActiveRecord::Base` | `active_record` | +| `ActiveRecord::DatabaseConfigurations` | `active_record_database_configurations` | | `ActiveRecord::Encryption` | `active_record_encryption` | | `ActiveRecord::TestFixtures` | `active_record_fixtures` | | `ActiveRecord::ConnectionAdapters::PostgreSQLAdapter` | `active_record_postgresqladapter` | From 9aae4571ef86fb08a1af3ad4d2a7050ec8b35771 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Wed, 23 Apr 2025 10:52:05 -0400 Subject: [PATCH 0124/1075] Sort schema cache columns and indexes per table when dumping ref: https://github.com/rails/rails/issues/42717 ref: https://github.com/rails/rails/pull/48824 This allow to result to be consistent, for example allowing use of its digest for cache keys. --- .../connection_adapters/schema_cache.rb | 4 ++-- .../cases/connection_adapters/schema_cache_test.rb | 12 ++++++++---- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/activerecord/lib/active_record/connection_adapters/schema_cache.rb b/activerecord/lib/active_record/connection_adapters/schema_cache.rb index 77ea0f46e7799..aded7283aa7d7 100644 --- a/activerecord/lib/active_record/connection_adapters/schema_cache.rb +++ b/activerecord/lib/active_record/connection_adapters/schema_cache.rb @@ -271,10 +271,10 @@ def initialize_dup(other) # :nodoc: end def encode_with(coder) # :nodoc: - coder["columns"] = @columns.sort.to_h + coder["columns"] = @columns.sort.to_h.transform_values { _1.sort_by(&:name) } coder["primary_keys"] = @primary_keys.sort.to_h coder["data_sources"] = @data_sources.sort.to_h - coder["indexes"] = @indexes.sort.to_h + coder["indexes"] = @indexes.sort.to_h.transform_values { _1.sort_by(&:name) } coder["version"] = @version end diff --git a/activerecord/test/cases/connection_adapters/schema_cache_test.rb b/activerecord/test/cases/connection_adapters/schema_cache_test.rb index 992a6896099be..ebd42efb3e71a 100644 --- a/activerecord/test/cases/connection_adapters/schema_cache_test.rb +++ b/activerecord/test/cases/connection_adapters/schema_cache_test.rb @@ -467,11 +467,15 @@ def test_when_lazily_load_schema_cache_is_set_cache_is_lazily_populated_when_est values = [["z", nil], ["y", nil], ["x", nil]] expected = values.sort.to_h + named = Struct.new(:name) + named_values = [["z", [named.new("c"), named.new("b")]], ["y", [named.new("c"), named.new("b")]], ["x", [named.new("c"), named.new("b")]]] + named_expected = named_values.sort.to_h.transform_values { _1.sort_by(&:name) } + coder = { - "columns" => values, + "columns" => named_values, "primary_keys" => values, "data_sources" => values, - "indexes" => values, + "indexes" => named_values, "deduplicated" => true } @@ -479,10 +483,10 @@ def test_when_lazily_load_schema_cache_is_set_cache_is_lazily_populated_when_est schema_cache.init_with(coder) schema_cache.encode_with(coder) - assert_equal expected, coder["columns"] + assert_equal named_expected, coder["columns"] assert_equal expected, coder["primary_keys"] assert_equal expected, coder["data_sources"] - assert_equal expected, coder["indexes"] + assert_equal named_expected, coder["indexes"] assert coder.key?("version") end From 68cb3af8d5f3bcd4bd9b942133ac3a3bbf30c353 Mon Sep 17 00:00:00 2001 From: viralpraxis Date: Thu, 24 Apr 2025 14:48:28 +0300 Subject: [PATCH 0125/1075] Tweak `ActiveSupport::JSON.decode` method description I'm not sure if back in the day `MultiJson.decode` could decode non-object JSON objects -- but `JSON.parse` sure can now. ```ruby bundle exec rails runner 'p ActiveSupport::JSON.decode("2.39")' 2.39 ``` ref: https://github.com/rails/rails/commit/21fee1b6540871c0227b97f2312573a4f0d89127 --- activesupport/lib/active_support/json/decoding.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/activesupport/lib/active_support/json/decoding.rb b/activesupport/lib/active_support/json/decoding.rb index e568d61c99a0d..c9f49086b43f7 100644 --- a/activesupport/lib/active_support/json/decoding.rb +++ b/activesupport/lib/active_support/json/decoding.rb @@ -14,11 +14,13 @@ module JSON DATETIME_REGEX = /\A(?:\d{4}-\d{2}-\d{2}|\d{4}-\d{1,2}-\d{1,2}[T \t]+\d{1,2}:\d{2}:\d{2}(\.[0-9]*)?(([ \t]*)Z|[-+]\d{2}?(:\d{2})?)?)\z/ class << self - # Parses a JSON string (JavaScript Object Notation) into a hash. + # Parses a JSON string (JavaScript Object Notation) into a Ruby object. # See http://www.json.org for more info. # # ActiveSupport::JSON.decode("{\"team\":\"rails\",\"players\":\"36\"}") # => {"team" => "rails", "players" => "36"} + # ActiveSupport::JSON.decode("2.39") + # => 2.39 def decode(json, options = {}) data = ::JSON.parse(json, options) From 4dfef48e911aa13def46f4408ad53347d4985bf5 Mon Sep 17 00:00:00 2001 From: SaschaSchwarz Date: Thu, 24 Apr 2025 15:19:03 +0200 Subject: [PATCH 0126/1075] Update form_helpers.md --- guides/source/form_helpers.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/guides/source/form_helpers.md b/guides/source/form_helpers.md index 57d4d3a78d4d5..91d3fca7be58e 100644 --- a/guides/source/form_helpers.md +++ b/guides/source/form_helpers.md @@ -358,7 +358,7 @@ The above will produce the following output: The object yielded by `fields_for` is a form builder like the one yielded by `form_with`. The `fields_for` helper creates a similar binding but without -rendering a `
` tag. You can learn more about `field_for` in the [API +rendering a `` tag. You can learn more about `fields_for` in the [API docs](https://api.rubyonrails.org/classes/ActionView/Helpers/FormBuilder.html#method-i-fields_for). ### Relying on Record Identification From da317b54119e075fa0e14493822470085ab63219 Mon Sep 17 00:00:00 2001 From: An Cao Date: Thu, 24 Apr 2025 21:21:28 +0700 Subject: [PATCH 0127/1075] Fix a typo in the Active Record Query Interface guide --- guides/source/active_record_querying.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/guides/source/active_record_querying.md b/guides/source/active_record_querying.md index 41fa48d97192f..eba81b9f60637 100644 --- a/guides/source/active_record_querying.md +++ b/guides/source/active_record_querying.md @@ -199,7 +199,7 @@ 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: +If your table uses a composite primary key, you'll need to pass in 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 From 474ff5556f59c47c12e028050f35324400c4dafe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Lavoie?= Date: Thu, 24 Apr 2025 09:43:30 -0400 Subject: [PATCH 0128/1075] [actionview] [Fix #54966] Check choice type before calling .second to detect grouped_choices in Select helper --- actionview/lib/action_view/helpers/tags/select.rb | 7 ++++++- actionview/test/template/form_options_helper_test.rb | 7 +++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/actionview/lib/action_view/helpers/tags/select.rb b/actionview/lib/action_view/helpers/tags/select.rb index 0e3de4afd8540..e0a3e69ce4298 100644 --- a/actionview/lib/action_view/helpers/tags/select.rb +++ b/actionview/lib/action_view/helpers/tags/select.rb @@ -37,7 +37,12 @@ def render # [nil, []] # { nil => [] } def grouped_choices? - !@choices.blank? && @choices.first.respond_to?(:second) && Array === @choices.first.second + return false if @choices.blank? + + first_choice = @choices.first + return false unless first_choice.is_a?(Enumerable) + + first_choice.second.is_a?(Array) end end end diff --git a/actionview/test/template/form_options_helper_test.rb b/actionview/test/template/form_options_helper_test.rb index d48c3bea8cf13..0f68f541c7aea 100644 --- a/actionview/test/template/form_options_helper_test.rb +++ b/actionview/test/template/form_options_helper_test.rb @@ -537,6 +537,13 @@ def test_select ) end + def test_select_with_class + assert_dom_equal( + "", + select(:post, :class, [Post]) + ) + end + def test_select_without_multiple assert_dom_equal( "", From 267d39af501dfdcd7ff64768726465c6d1a6c6c8 Mon Sep 17 00:00:00 2001 From: An Cao Date: Sun, 27 Apr 2025 08:01:30 +0700 Subject: [PATCH 0129/1075] Clarify language and fix minor grammatical issues in Active Record Querying guide --- guides/source/active_record_querying.md | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/guides/source/active_record_querying.md b/guides/source/active_record_querying.md index eba81b9f60637..c244b02c43d4b 100644 --- a/guides/source/active_record_querying.md +++ b/guides/source/active_record_querying.md @@ -244,7 +244,7 @@ SELECT * FROM customers LIMIT 1 The `take` method returns `nil` if no record is found and no exception will be raised. -You can pass in a numerical argument to the `take` method to return up to that number of results. For example +You can pass in a numerical argument to the `take` method to return up to that number of results. For example: ```irb irb> customers = Customer.take(2) @@ -283,7 +283,7 @@ The `first` method returns `nil` if no matching record is found and no exception If your [default scope](active_record_querying.html#applying-a-default-scope) contains an order method, `first` will return the first record according to this ordering. -You can pass in a numerical argument to the `first` method to return up to that number of results. For example +You can pass in a numerical argument to the `first` method to return up to that number of results. For example: ```irb irb> customers = Customer.first(3) @@ -361,7 +361,7 @@ SELECT * FROM customers ORDER BY customers.store_id DESC, customers.id DESC LIMI 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 +You can pass in a numerical argument to the `last` method to return up to that number of results. For example: ```irb irb> customers = Customer.last(3) @@ -978,7 +978,7 @@ Limit and Offset To apply `LIMIT` to the SQL fired by the `Model.find`, you can specify the `LIMIT` using [`limit`][] and [`offset`][] methods on the relation. -You can use `limit` to specify the number of records to be retrieved, and use `offset` to specify the number of records to skip before starting to return the records. For example +You can use `limit` to specify the number of records to be retrieved, and use `offset` to specify the number of records to skip before starting to return the records. For example: ```ruby Customer.limit(5) @@ -1150,7 +1150,7 @@ Compare this to the case where the `reselect` clause is not used: Book.select(:title, :isbn).select(:created_at) ``` -the SQL executed would be: +The SQL executed would be: ```sql SELECT books.title, books.isbn, books.created_at FROM books @@ -1158,7 +1158,7 @@ SELECT books.title, books.isbn, books.created_at FROM books ### `reorder` -The [`reorder`][] method overrides the default scope order. For example if the class definition includes this: +The [`reorder`][] method overrides the default scope order. For example, if the class definition includes this: ```ruby class Author < ApplicationRecord @@ -1240,7 +1240,7 @@ If the `rewhere` clause is not used, the where clauses are ANDed together: Book.where(out_of_print: true).where(out_of_print: false) ``` -the SQL executed would be: +The SQL executed would be: ```sql SELECT * FROM books WHERE out_of_print = 1 AND out_of_print = 0 @@ -1269,7 +1269,7 @@ If the `regroup` clause is not used, the group clauses are combined together: Book.group(:author).group(:id) ``` -the SQL executed would be: +The SQL executed would be: ```sql SELECT * FROM books GROUP BY author, id @@ -1734,7 +1734,7 @@ NOTE: The `preload` method uses an array, hash, or a nested hash of array/hash i With `eager_load`, Active Record loads all specified associations using a `LEFT OUTER JOIN`. -Revisiting the case where N + 1 was occurred using the `eager_load` method, we could rewrite `Book.limit(10)` to authors: +Revisiting the case where N + 1 was occurred using the `eager_load` method, we could rewrite `Book.limit(10)` to eager load authors: ```ruby books = Book.eager_load(:author).limit(10) @@ -2133,7 +2133,7 @@ Understanding Method Chaining ----------------------------- The Active Record pattern implements [Method Chaining](https://en.wikipedia.org/wiki/Method_chaining), -which allow us to use multiple Active Record methods together in a simple and straightforward way. +which allows us to use multiple Active Record methods together in a simple and straightforward way. You can chain methods in a statement when the previous method called returns an [`ActiveRecord::Relation`][], like `all`, `where`, and `joins`. Methods that return @@ -2294,7 +2294,7 @@ irb> nina.save Finding by SQL -------------- -If you'd like to use your own SQL to find records in a table you can use [`find_by_sql`][]. The `find_by_sql` method will return an array of objects even if the underlying query returns just a single record. For example you could run this query: +If you'd like to use your own SQL to find records in a table you can use [`find_by_sql`][]. The `find_by_sql` method will return an array of objects even if the underlying query returns just a single record. For example, you could run this query: ```irb irb> Customer.find_by_sql("SELECT * FROM customers INNER JOIN orders ON customers.id = orders.customer_id ORDER BY customers.created_at desc") From 78f06aa71a62001c808010fa76276880e6218799 Mon Sep 17 00:00:00 2001 From: Jenny Shen Date: Wed, 23 Apr 2025 12:45:51 -0400 Subject: [PATCH 0130/1075] Set primary key insert default for insert_all and upsert_all In Postgres, a PG::NotNullViolation is raised when the primary key value is nil. This limits the ability to update and insert new records in one bulk upsert. --- activerecord/CHANGELOG.md | 8 ++++++++ .../abstract/database_statements.rb | 14 +++++++------- .../mysql/database_statements.rb | 8 ++++---- .../sqlite3/database_statements.rb | 16 ++++++++-------- activerecord/lib/active_record/insert_all.rb | 15 ++++++++++++--- activerecord/test/cases/insert_all_test.rb | 18 ++++++++++++++++++ 6 files changed, 57 insertions(+), 22 deletions(-) diff --git a/activerecord/CHANGELOG.md b/activerecord/CHANGELOG.md index 84da22fef1a10..5212170bec45f 100644 --- a/activerecord/CHANGELOG.md +++ b/activerecord/CHANGELOG.md @@ -1,3 +1,11 @@ +* Set default for primary keys in `insert_all`/`upsert_all`. + + Previously in Postgres, updating and inserting new records in one upsert wasn't possible + due to null primary key values. `nil` primary key values passed into `insert_all`/`upsert_all` + are now implicitly set to the default insert value specified by adapter. + + *Jenny Shen* + * Add a load hook `active_record_database_configurations` for `ActiveRecord::DatabaseConfigurations` *Mike Dalessio* 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 3bf9dbf6bb137..e3b4fdb0e1832 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb @@ -550,7 +550,14 @@ def internal_exec_query(...) # :nodoc: cast_result(internal_execute(...)) end + def default_insert_value(column) # :nodoc: + DEFAULT_INSERT_VALUE + end + private + DEFAULT_INSERT_VALUE = Arel.sql("DEFAULT").freeze + private_constant :DEFAULT_INSERT_VALUE + # Lowest level way to execute a query. Doesn't check for illegal writes, doesn't annotate queries, yields a native result object. def raw_execute(sql, name = nil, binds = [], prepare: false, async: false, allow_retry: false, materialize_transactions: true, batch: false) type_casted_binds = type_casted_binds(binds) @@ -607,13 +614,6 @@ def execute_batch(statements, name = nil, **kwargs) end end - DEFAULT_INSERT_VALUE = Arel.sql("DEFAULT").freeze - private_constant :DEFAULT_INSERT_VALUE - - def default_insert_value(column) - DEFAULT_INSERT_VALUE - end - def build_fixture_sql(fixtures, table_name) columns = schema_cache.columns_hash(table_name).reject { |_, column| supports_virtual_columns? && column.virtual? } diff --git a/activerecord/lib/active_record/connection_adapters/mysql/database_statements.rb b/activerecord/lib/active_record/connection_adapters/mysql/database_statements.rb index a358cd2b2b6a5..c1f8050207d12 100644 --- a/activerecord/lib/active_record/connection_adapters/mysql/database_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/mysql/database_statements.rb @@ -45,16 +45,16 @@ def build_explain_clause(options = []) end end + def default_insert_value(column) # :nodoc: + super unless column.auto_increment? + end + private # https://mariadb.com/kb/en/analyze-statement/ def analyze_without_explain? mariadb? && database_version >= "10.1.0" end - def default_insert_value(column) - super unless column.auto_increment? - end - def returning_column_values(result) if supports_insert_returning? result.rows.first 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 b8612a1787e2c..03d78a6f7e793 100644 --- a/activerecord/lib/active_record/connection_adapters/sqlite3/database_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/sqlite3/database_statements.rb @@ -61,6 +61,14 @@ def reset_isolation_level # :nodoc: @previous_read_uncommitted = nil end + def default_insert_value(column) # :nodoc: + if column.default_function + Arel.sql(column.default_function) + else + column.default + end + end + private def internal_begin_transaction(mode, isolation) if isolation @@ -136,14 +144,6 @@ def build_truncate_statement(table_name) def returning_column_values(result) result.rows.first end - - def default_insert_value(column) - if column.default_function - Arel.sql(column.default_function) - else - column.default - end - end end end end diff --git a/activerecord/lib/active_record/insert_all.rb b/activerecord/lib/active_record/insert_all.rb index 63dd142ecbc06..c603e112f1643 100644 --- a/activerecord/lib/active_record/insert_all.rb +++ b/activerecord/lib/active_record/insert_all.rb @@ -225,7 +225,7 @@ def timestamps_for_create class Builder # :nodoc: attr_reader :model - delegate :skip_duplicates?, :update_duplicates?, :keys, :keys_including_timestamps, :record_timestamps?, to: :insert_all + delegate :skip_duplicates?, :update_duplicates?, :keys, :keys_including_timestamps, :record_timestamps?, :primary_keys, to: :insert_all def initialize(insert_all) @insert_all, @model, @connection = insert_all, insert_all.model, insert_all.connection @@ -239,8 +239,13 @@ def values_list types = extract_types_from_columns_on(model.table_name, keys: keys_including_timestamps) values_list = insert_all.map_key_with_value do |key, value| - next value if Arel::Nodes::SqlLiteral === value - ActiveModel::Type::SerializeCastValue.serialize(type = types[key], type.cast(value)) + if Arel::Nodes::SqlLiteral === value + value + elsif primary_keys.include?(key) && value.nil? + connection.default_insert_value(column_from_key(key)) + else + ActiveModel::Type::SerializeCastValue.serialize(type = types[key], type.cast(value)) + end end connection.visitor.compile(Arel::Nodes::ValuesList.new(values_list)) @@ -323,6 +328,10 @@ def quote_columns(columns) def quote_column(column) connection.quote_column_name(column) end + + def column_from_key(key) + model.schema_cache.columns_hash(model.table_name)[key] + end end end end diff --git a/activerecord/test/cases/insert_all_test.rb b/activerecord/test/cases/insert_all_test.rb index c5455aec36770..956988d11f7ec 100644 --- a/activerecord/test/cases/insert_all_test.rb +++ b/activerecord/test/cases/insert_all_test.rb @@ -409,6 +409,24 @@ def test_upsert_all_updates_existing_record_by_primary_key assert_equal "New edition", Book.find(1).name end + def test_upsert_all_implicitly_sets_primary_keys_when_nil + assert_difference "Book.count", 2 do + Book.upsert_all [ + { id: 1, name: "New edition" }, + { id: nil, name: "New edition 2" }, + { id: nil, name: "New edition 3" }, + ] + end + + assert_equal "New edition", Book.find(1).name + end + + def test_insert_all_implicitly_sets_primary_keys_when_nil + assert_difference "Book.count", 1 do + Book.insert_all [ { id: nil, name: "New edition" } ] + end + end + def test_upsert_all_only_applies_last_value_when_given_duplicate_identifiers skip unless supports_insert_on_duplicate_update? && !current_adapter?(:PostgreSQLAdapter) From a596c38e9264e44270ab90fc3a0011587e76388e Mon Sep 17 00:00:00 2001 From: Lee Sheppard Date: Tue, 29 Apr 2025 12:18:24 +1000 Subject: [PATCH 0131/1075] Update Major Features section of release notes for Rails version 8.0 --- guides/source/8_0_release_notes.md | 49 ++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/guides/source/8_0_release_notes.md b/guides/source/8_0_release_notes.md index 158d020fdcc17..b0043973bee40 100644 --- a/guides/source/8_0_release_notes.md +++ b/guides/source/8_0_release_notes.md @@ -21,6 +21,55 @@ guide. Major Features -------------- +### Kamal 2 + +Rails now comes preconfigured with [Kamal 2](https://kamal-deploy.org/) for +deploying your application. Kamal takes a fresh Linux box and turns it into an +application or accessory server with just a single “kamal setup” command. + +Kamal 2 also includes a proxy called [Kamal Proxy](https://github.com/basecamp/kamal-proxy) +to replace the generic Traefik option it used at launch. + +### Thruster + +The Dockerfile has been upgraded to include a new proxy called +[Thruster](https://github.com/basecamp/thruster), which sits in front of the +Puma web server to provide X-Sendfile acceleration, asset caching, and asset +compression. + +### Solid Cable + +[Solid Cable](https://github.com/rails/solid_cable) replaces Redis to act as +the pubsub server to relay WebSocket messages from the application to clients +connected to different processes. Solid Cable retains the messages sent in +the database for a day by default. + +### Solid Cache + +[Solid Cache](https://github.com/rails/solid_cache) replaces either +Redis or Memcached for storing HTML fragment caches in particular. + +### Solid Queue + +[Solid Queue](https://github.com/rails/solid_queue) replaces the need for +Redis, also a separate job-running framework, like Resque, Delayed Job, or +Sidekiq. + +For high-performance installations, it’s built on the new `FOR UPDATE SKIP LOCKED` +mechanism first introduced in PostgreSQL 9.5, but now also available in MySQL 8.0 +and beyond. It also works with SQLite. + +### Propshaft + +[Propshaft](https://github.com/rails/propshaft) is now the default asset +pipeline, replacing the old Sprockets system. + +### Authentication + +[Authentication system generator](https://github.com/rails/rails/pull/52328), +creates a starting point for a session-based, password-resettable, +metadata-tracking authentication system. + Railties -------- From bc7f77b32d7f3f26db654cae506c26a959852506 Mon Sep 17 00:00:00 2001 From: Xavier Noria Date: Mon, 28 Apr 2025 17:56:48 +0200 Subject: [PATCH 0132/1075] Linting I am working on this file in another branch and my OCD saw some missing parens. Prefer this to be an independent patch. --- .../lib/active_record/associations.rb | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/activerecord/lib/active_record/associations.rb b/activerecord/lib/active_record/associations.rb index 72c4172e37846..2b3675fecf0fe 100644 --- a/activerecord/lib/active_record/associations.rb +++ b/activerecord/lib/active_record/associations.rb @@ -1301,7 +1301,7 @@ module ClassMethods # has_many :comments, index_errors: :nested_attributes_order def has_many(name, scope = nil, **options, &extension) reflection = Builder::HasMany.build(self, name, scope, options, &extension) - Reflection.add_reflection self, name, reflection + Reflection.add_reflection(self, name, reflection) end # Specifies a one-to-one association with another class. This method @@ -1497,7 +1497,7 @@ def has_many(name, scope = nil, **options, &extension) # 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 + Reflection.add_reflection(self, name, reflection) end # Specifies a one-to-one association with another class. This method @@ -1688,7 +1688,7 @@ def has_one(name, scope = nil, **options) # belongs_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 + Reflection.add_reflection(self, name, reflection) end # Specifies a many-to-many relationship with another class. This associates two classes via an @@ -1870,17 +1870,17 @@ def belongs_to(name, scope = nil, **options) def has_and_belongs_to_many(name, scope = nil, **options, &extension) habtm_reflection = ActiveRecord::Reflection::HasAndBelongsToManyReflection.new(name, scope, options, self) - builder = Builder::HasAndBelongsToMany.new name, self, options + builder = Builder::HasAndBelongsToMany.new(name, self, options) join_model = builder.through_model - const_set join_model.name, join_model - private_constant join_model.name + const_set(join_model.name, join_model) + private_constant(join_model.name) - middle_reflection = builder.middle_reflection join_model + middle_reflection = builder.middle_reflection(join_model) - Builder::HasMany.define_callbacks self, middle_reflection - Reflection.add_reflection self, middle_reflection.name, middle_reflection + Builder::HasMany.define_callbacks(self, middle_reflection) + Reflection.add_reflection(self, middle_reflection.name, middle_reflection) middle_reflection.parent_reflection = habtm_reflection include Module.new { @@ -1898,7 +1898,7 @@ def destroy_associations hm_options[:source] = join_model.right_reflection.name [:before_add, :after_add, :before_remove, :after_remove, :autosave, :validate, :join_table, :class_name, :extend, :strict_loading].each do |k| - hm_options[k] = options[k] if options.key? k + hm_options[k] = options[k] if options.key?(k) end has_many name, scope, **hm_options, &extension From 4275e5828629bb3b919e18a579658febc4a7e6b9 Mon Sep 17 00:00:00 2001 From: Sam Partington Date: Tue, 29 Apr 2025 09:25:48 +0100 Subject: [PATCH 0133/1075] Fix typo in schema_statements.rb --- .../connection_adapters/abstract/schema_statements.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 5648694a908a0..9989afd85dc24 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb @@ -548,7 +548,7 @@ def drop_table(*table_names, **options) # # See {ActiveRecord::ConnectionAdapters::TableDefinition.column}[rdoc-ref:ActiveRecord::ConnectionAdapters::TableDefinition#column]. # - # The +type+ parameter is normally one of the migrations native types, + # The +type+ parameter is normally one of the migration's native types, # which is one of the following: # :primary_key, :string, :text, # :integer, :bigint, :float, :decimal, :numeric, From a53734bd582dc2bd3b794ba395d938f38612f761 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Tue, 29 Apr 2025 07:07:01 -0400 Subject: [PATCH 0134/1075] dep: bump trix to v2.1.14 which updates DOMPurify to 3.2.5 to address CVE-2025-26791 ref: https://github.com/advisories/GHSA-vhxf-7vqr-mrjg --- actiontext/app/assets/javascripts/trix.js | 56 +++++++++++++++++------ 1 file changed, 43 insertions(+), 13 deletions(-) diff --git a/actiontext/app/assets/javascripts/trix.js b/actiontext/app/assets/javascripts/trix.js index 460ff32326ddc..c1eae1566aea3 100644 --- a/actiontext/app/assets/javascripts/trix.js +++ b/actiontext/app/assets/javascripts/trix.js @@ -1,6 +1,6 @@ /* -Trix 2.1.12 -Copyright © 2024 37signals, LLC +Trix 2.1.14 +Copyright © 2025 37signals, LLC */ (function (global, factory) { typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : @@ -9,7 +9,7 @@ Copyright © 2024 37signals, LLC })(this, (function () { 'use strict'; var name = "trix"; - var version = "2.1.12"; + var version = "2.1.14"; var description = "A rich text editor for everyday writing"; var main = "dist/trix.umd.min.js"; var module = "dist/trix.esm.min.js"; @@ -80,7 +80,7 @@ Copyright © 2024 37signals, LLC start: "yarn build-assets && concurrently --kill-others --names js,css,dev-server 'yarn watch' 'yarn build-css --watch' 'yarn dev'" }; var dependencies = { - dompurify: "^3.2.3" + dompurify: "^3.2.5" }; var _package = { name: name, @@ -1742,7 +1742,7 @@ $\ } } - /*! @license DOMPurify 3.2.3 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.2.3/LICENSE */ + /*! @license DOMPurify 3.2.5 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.2.5/LICENSE */ const { entries, @@ -1781,8 +1781,10 @@ $\ }; } const arrayForEach = unapply(Array.prototype.forEach); + const arrayLastIndexOf = unapply(Array.prototype.lastIndexOf); const arrayPop = unapply(Array.prototype.pop); const arrayPush = unapply(Array.prototype.push); + const arraySplice = unapply(Array.prototype.splice); const stringToLowerCase = unapply(String.prototype.toLowerCase); const stringToString = unapply(String.prototype.toString); const stringMatch = unapply(String.prototype.match); @@ -1800,6 +1802,9 @@ $\ */ function unapply(func) { return function (thisArg) { + if (thisArg instanceof RegExp) { + thisArg.lastIndex = 0; + } for (var _len = arguments.length, args = new Array(_len > 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) { args[_key - 1] = arguments[_key]; } @@ -1936,7 +1941,7 @@ $\ // eslint-disable-next-line unicorn/better-regex const MUSTACHE_EXPR = seal(/\{\{[\w\W]*|[\w\W]*\}\}/gm); // Specify template detection regex for SAFE_FOR_TEMPLATES mode const ERB_EXPR = seal(/<%[\w\W]*|[\w\W]*%>/gm); - const TMPLIT_EXPR = seal(/\$\{[\w\W]*}/gm); // eslint-disable-line unicorn/better-regex + const TMPLIT_EXPR = seal(/\$\{[\w\W]*/gm); // eslint-disable-line unicorn/better-regex const DATA_ATTR = seal(/^data-[\-\w.\u00B7-\uFFFF]+$/); // eslint-disable-line no-useless-escape const ARIA_ATTR = seal(/^aria-[\-\w]+$/); // eslint-disable-line no-useless-escape const IS_ALLOWED_URI = seal(/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i // eslint-disable-line no-useless-escape @@ -2038,9 +2043,9 @@ $\ function createDOMPurify() { let window = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : getGlobal(); const DOMPurify = root => createDOMPurify(root); - DOMPurify.version = '3.2.3'; + DOMPurify.version = '3.2.5'; DOMPurify.removed = []; - if (!window || !window.document || window.document.nodeType !== NODE_TYPE.document) { + if (!window || !window.document || window.document.nodeType !== NODE_TYPE.document || !window.Element) { // Not running in a browser, provide a factory function // so that you can pass your own Window DOMPurify.isSupported = false; @@ -2643,7 +2648,7 @@ $\ allowedTags: ALLOWED_TAGS }); /* Detect mXSS attempts abusing namespace confusion */ - if (currentNode.hasChildNodes() && !_isNode(currentNode.firstElementChild) && regExpTest(/<[/\w]/g, currentNode.innerHTML) && regExpTest(/<[/\w]/g, currentNode.textContent)) { + if (currentNode.hasChildNodes() && !_isNode(currentNode.firstElementChild) && regExpTest(/<[/\w!]/g, currentNode.innerHTML) && regExpTest(/<[/\w!]/g, currentNode.textContent)) { _forceRemove(currentNode); return true; } @@ -3059,7 +3064,11 @@ $\ } arrayPush(hooks[entryPoint], hookFunction); }; - DOMPurify.removeHook = function (entryPoint) { + DOMPurify.removeHook = function (entryPoint, hookFunction) { + if (hookFunction !== undefined) { + const index = arrayLastIndexOf(hooks[entryPoint], hookFunction); + return index === -1 ? undefined : arraySplice(hooks[entryPoint], index, 1)[0]; + } return arrayPop(hooks[entryPoint]); }; DOMPurify.removeHooks = function (entryPoint) { @@ -13580,11 +13589,11 @@ $\ } else if (this.parentNode) { const toolbarId = "trix-toolbar-".concat(this.trixId); this.setAttribute("toolbar", toolbarId); - const element = makeElement("trix-toolbar", { + this.internalToolbar = makeElement("trix-toolbar", { id: toolbarId }); - this.parentNode.insertBefore(element, this); - return element; + this.parentNode.insertBefore(this.internalToolbar, this); + return this.internalToolbar; } else { return undefined; } @@ -13628,6 +13637,14 @@ $\ (_this$editor = this.editor) === null || _this$editor === void 0 || _this$editor.loadHTML(this.defaultValue); } + // Element callbacks + + attributeChangedCallback(name, oldValue, newValue) { + if (name === "connected" && this.isConnected && oldValue != null && oldValue !== newValue) { + requestAnimationFrame(() => this.reconnect()); + } + } + // Controller delegate methods notify(message, data) { @@ -13665,6 +13682,7 @@ $\ } this.editorController.registerSelectionManager(); _classPrivateFieldGet(this, _delegate).connectedCallback(); + this.toggleAttribute("connected", true); autofocus(this); } } @@ -13672,6 +13690,17 @@ $\ var _this$editorControlle2; (_this$editorControlle2 = this.editorController) === null || _this$editorControlle2 === void 0 || _this$editorControlle2.unregisterSelectionManager(); _classPrivateFieldGet(this, _delegate).disconnectedCallback(); + this.toggleAttribute("connected", false); + } + reconnect() { + this.removeInternalToolbar(); + this.disconnectedCallback(); + this.connectedCallback(); + } + removeInternalToolbar() { + var _this$internalToolbar; + (_this$internalToolbar = this.internalToolbar) === null || _this$internalToolbar === void 0 || _this$internalToolbar.remove(); + this.internalToolbar = null; } // Form support @@ -13699,6 +13728,7 @@ $\ } } _defineProperty(TrixEditorElement, "formAssociated", "ElementInternals" in window); + _defineProperty(TrixEditorElement, "observedAttributes", ["connected"]); var elements = /*#__PURE__*/Object.freeze({ __proto__: null, From 1f98fd2bbe04d38fea5138c3401ed481bdb38520 Mon Sep 17 00:00:00 2001 From: eileencodes Date: Mon, 28 Apr 2025 14:34:38 -0400 Subject: [PATCH 0135/1075] Implement ability to opt out of parallel database hooks Preivously, you could append your own hooks to parallel tests, but there was no way to skip the Rails database parallelization hooks. There may be cases where you want to implement your own database handling, so this allows you to implement all of that in a `parallelize_setup` block without having Rails create any databases for you. To skip Rails database parallelization when running parallel tests you can set it in the `parallelize` caller: ```ruby parallelize(workers: 10, parallelize_databases: false) ``` Or in your application config: ```ruby config.active_support.parallelize_test_databases = false ``` This is set to true by default. Note: if you do not create databases for the processes, it is possible for your test suite to deadlock. If you're seeing frequent deadlocks, you may need to create more databases for the processes to use or adjust your tests/code so it's less likely to cause database contention. --- .../lib/active_record/test_databases.rb | 8 ++++-- .../test/cases/test_databases_test.rb | 26 +++++++++++++++++++ activesupport/CHANGELOG.md | 21 +++++++++++++++ activesupport/lib/active_support.rb | 1 + activesupport/lib/active_support/test_case.rb | 15 ++++++++++- .../test/application/configuration_test.rb | 14 ++++++++++ 6 files changed, 82 insertions(+), 3 deletions(-) diff --git a/activerecord/lib/active_record/test_databases.rb b/activerecord/lib/active_record/test_databases.rb index 16ad1dba4495a..cb3fdb54f0ed2 100644 --- a/activerecord/lib/active_record/test_databases.rb +++ b/activerecord/lib/active_record/test_databases.rb @@ -5,11 +5,15 @@ module ActiveRecord module TestDatabases # :nodoc: ActiveSupport::Testing::Parallelization.before_fork_hook do - ActiveRecord::Base.connection_handler.clear_all_connections! + if ActiveSupport.parallelize_test_databases + ActiveRecord::Base.connection_handler.clear_all_connections! + end end ActiveSupport::Testing::Parallelization.after_fork_hook do |i| - create_and_load_schema(i, env_name: ActiveRecord::ConnectionHandling::DEFAULT_ENV.call) + if ActiveSupport.parallelize_test_databases + create_and_load_schema(i, env_name: ActiveRecord::ConnectionHandling::DEFAULT_ENV.call) + end end def self.create_and_load_schema(i, env_name:) diff --git a/activerecord/test/cases/test_databases_test.rb b/activerecord/test/cases/test_databases_test.rb index 2efd26c6844b3..680512207bd67 100644 --- a/activerecord/test/cases/test_databases_test.rb +++ b/activerecord/test/cases/test_databases_test.rb @@ -53,6 +53,32 @@ def test_create_databases_after_fork ENV["RAILS_ENV"] = previous_env end + def test_create_databases_skipped_if_parallelize_test_databases_is_false + parallelize_databases = ActiveSupport.parallelize_test_databases + ActiveSupport.parallelize_test_databases = false + + previous_env, ENV["RAILS_ENV"] = ENV["RAILS_ENV"], "arunit" + prev_configs, ActiveRecord::Base.configurations = ActiveRecord::Base.configurations, { + "arunit" => { + "primary" => { "adapter" => "sqlite3", "database" => "test/db/primary.sqlite3" } + } + } + + idx = 42 + base_db_config = ActiveRecord::Base.configurations.configs_for(env_name: "arunit", name: "primary") + expected_database = "#{base_db_config.database}" + + ActiveSupport::Testing::Parallelization.after_fork_hooks.each { |cb| cb.call(idx) } + + # In this case, there should be no updates + assert_equal expected_database, ActiveRecord::Base.configurations.configs_for(env_name: "arunit", name: "primary").database + ensure + ActiveSupport.parallelize_test_databases = parallelize_databases + ActiveRecord::Base.configurations = prev_configs + ActiveRecord::Base.establish_connection(:arunit) + ENV["RAILS_ENV"] = previous_env + end + def test_order_of_configurations_isnt_changed_by_test_databases previous_env, ENV["RAILS_ENV"] = ENV["RAILS_ENV"], "arunit" prev_configs, ActiveRecord::Base.configurations = ActiveRecord::Base.configurations, { diff --git a/activesupport/CHANGELOG.md b/activesupport/CHANGELOG.md index a162c556b6be1..4e7d30f51de9f 100644 --- a/activesupport/CHANGELOG.md +++ b/activesupport/CHANGELOG.md @@ -1,3 +1,24 @@ +* Implement ability to skip creating parallel testing databases. + + With parallel testing, Rails will create a database per process. If this isn't + desirable or you would like to implement databases handling on your own, you can + now turn off this default behavior. + + To skip creating a database per process, you can change it via the + `parallelize` method: + + ```ruby + parallelize(workers: 10, parallelize_databases: false) + ``` + + or via the application configuration: + + ```ruby + config.active_support.parallelize_databases = false + ``` + + *Eileen M. Uchitelle* + * Allow to configure maximum cache key sizes When the key exceeds the configured limit (250 bytes by default), it will be truncated and diff --git a/activesupport/lib/active_support.rb b/activesupport/lib/active_support.rb index c5ab56fde7861..5f8b61f32c405 100644 --- a/activesupport/lib/active_support.rb +++ b/activesupport/lib/active_support.rb @@ -104,6 +104,7 @@ def self.eager_load! cattr_accessor :test_order # :nodoc: cattr_accessor :test_parallelization_threshold, default: 50 # :nodoc: + cattr_accessor :parallelize_test_databases, default: true # :nodoc: @error_reporter = ActiveSupport::ErrorReporter.new singleton_class.attr_accessor :error_reporter # :nodoc: diff --git a/activesupport/lib/active_support/test_case.rb b/activesupport/lib/active_support/test_case.rb index 8554a09ed474f..37bc7f9006a11 100644 --- a/activesupport/lib/active_support/test_case.rb +++ b/activesupport/lib/active_support/test_case.rb @@ -79,7 +79,16 @@ def test_order # Because parallelization presents an overhead, it is only enabled when the # number of tests to run is above the +threshold+ param. The default value is # 50, and it's configurable via +config.active_support.test_parallelization_threshold+. - def parallelize(workers: :number_of_processors, with: :processes, threshold: ActiveSupport.test_parallelization_threshold) + # + # If you want to skip Rails default creation of one database per process in favor of + # writing your own implementation, you can set +parallelize_databases+, or configure it + # via +config.active_support.parallelize_test_databases+. + # + # parallelize(workers: :number_of_processes, parallelize_databases: false) + # + # Note that your test suite may deadlock if you attempt to use only one database + # with multiple processes. + def parallelize(workers: :number_of_processors, with: :processes, threshold: ActiveSupport.test_parallelization_threshold, parallelize_databases: ActiveSupport.parallelize_test_databases) case when ENV["PARALLEL_WORKERS"] workers = ENV["PARALLEL_WORKERS"].to_i @@ -87,6 +96,10 @@ def parallelize(workers: :number_of_processors, with: :processes, threshold: Act workers = (Concurrent.available_processor_count || Concurrent.processor_count).floor end + if with == :processes + ActiveSupport.parallelize_test_databases = parallelize_databases + end + Minitest.parallel_executor = ActiveSupport::Testing::ParallelizeExecutor.new(size: workers, with: with, threshold: threshold) end diff --git a/railties/test/application/configuration_test.rb b/railties/test/application/configuration_test.rb index 91ee66a011824..6ba841086fc2f 100644 --- a/railties/test/application/configuration_test.rb +++ b/railties/test/application/configuration_test.rb @@ -3204,6 +3204,20 @@ class Post < ActiveRecord::Base assert_equal 1234, ActiveSupport.test_parallelization_threshold end + test "ActiveSupport.parallelize_test_databases can be configured via config.active_support.parallelize_test_databases" do + remove_from_config '.*config\.load_defaults.*\n' + + app_file "config/environments/test.rb", <<-RUBY + Rails.application.configure do + config.active_support.parallelize_test_databases = false + end + RUBY + + app "test" + + assert_not ActiveSupport.parallelize_test_databases + end + test "custom serializers should be able to set via config.active_job.custom_serializers in an initializer" do class ::DummySerializer < ActiveJob::Serializers::ObjectSerializer; end From 0b627b74ab2d778fca7f8904e3d113fe1a723180 Mon Sep 17 00:00:00 2001 From: Andrey Sobolev Date: Wed, 30 Apr 2025 19:42:52 +0300 Subject: [PATCH 0136/1075] Merge pull request #54993 from duffuniverse/fix-typos-in-multiple-databases-guide [ci skip] Fix typos in the Multiple Databases with Active Record guide --- guides/source/active_record_multiple_databases.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/guides/source/active_record_multiple_databases.md b/guides/source/active_record_multiple_databases.md index 33f729fc8244b..c8444c1ae8b74 100644 --- a/guides/source/active_record_multiple_databases.md +++ b/guides/source/active_record_multiple_databases.md @@ -138,7 +138,7 @@ class Person < PrimaryApplicationRecord end ``` -On the other hand, we need to setup our models persisted in the "animals" database: +On the other hand, we need to set up our models persisted in the "animals" database: ```ruby class AnimalsRecord < ApplicationRecord @@ -212,7 +212,7 @@ db:setup:primary # Create the primary database, loads the sche Running a command like `bin/rails db:create` will create both the primary and animals databases. Note that there is no command for creating the database users, and you'll need to do that manually -to support the readonly users for your replicas. If you want to create just the animals +to support the read-only users for your replicas. If you want to create just the animals database you can run `bin/rails db:create:animals`. ## Connecting to Databases without Managing Schema and Migrations @@ -295,8 +295,8 @@ use a different parent class. Finally, in order to use the read-only replica in your application, you'll need to activate the middleware for automatic switching. -Automatic switching allows the application to switch from the writer to replica or replica -to writer based on the HTTP verb and whether there was a recent write by the requesting user. +Automatic switching allows the application to switch from the writer to the replica or the replica +to the writer based on the HTTP verb and whether there was a recent write by the requesting user. If the application receives a POST, PUT, DELETE, or PATCH request, the application will automatically write to the writer database. If the request is not one of those methods, @@ -328,7 +328,7 @@ to the replicas unless they wrote recently. The automatic connection switching in Rails is relatively primitive and deliberately doesn't do a whole lot. The goal is a system that demonstrates how to do automatic connection -switching that was flexible enough to be customizable by app developers. +switching that is flexible enough to be customizable by app developers. The setup in Rails allows you to easily change how the switching is done and what parameters it's based on. Let's say you want to use a cookie instead of a session to @@ -392,7 +392,7 @@ using the connection specification name. This means that if you pass an unknown like `connected_to(role: :nonexistent)` you will get an error that says `ActiveRecord::ConnectionNotEstablished (No connection pool for 'ActiveRecord::Base' found for the 'nonexistent' role.)` -If you want Rails to ensure any queries performed are read only, pass `prevent_writes: true`. +If you want Rails to ensure any queries performed are read-only, pass `prevent_writes: true`. This just prevents queries that look like writes from being sent to the database. You should also configure your replica database to run in read-only mode. @@ -517,7 +517,7 @@ end ``` Applications must provide a resolver to provide application-specific logic. An example resolver that -uses subdomain to determine the shard might look like this: +uses a subdomain to determine the shard might look like this: ```ruby config.active_record.shard_resolver = ->(request) { From 5696682601815fbf1408e4f8a8028cc0a5f86989 Mon Sep 17 00:00:00 2001 From: eileencodes Date: Mon, 28 Apr 2025 15:49:55 -0400 Subject: [PATCH 0137/1075] Add public API for `before_fork_hook` in parallel testing Previously there was no public API implemented for the `before_fork_hook` when using parallel testing. Applications may want to perform actions before fork, and we should provide an API for it like we did for setup and teardown. --- activesupport/CHANGELOG.md | 12 ++++++++++++ activesupport/lib/active_support/test_case.rb | 16 +++++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/activesupport/CHANGELOG.md b/activesupport/CHANGELOG.md index 4e7d30f51de9f..19bc05993c8a4 100644 --- a/activesupport/CHANGELOG.md +++ b/activesupport/CHANGELOG.md @@ -1,3 +1,15 @@ +* Add public API for `before_fork_hook` in parallel testing. + + Introduces a public API for calling the before fork hooks implemented by parallel testing. + + ```ruby + parallelize_before_fork do + # perform an action before test processes are forked + end + ``` + + *Eileen M. Uchitelle* + * Implement ability to skip creating parallel testing databases. With parallel testing, Rails will create a database per process. If this isn't diff --git a/activesupport/lib/active_support/test_case.rb b/activesupport/lib/active_support/test_case.rb index 37bc7f9006a11..f13c39506aab3 100644 --- a/activesupport/lib/active_support/test_case.rb +++ b/activesupport/lib/active_support/test_case.rb @@ -103,7 +103,21 @@ def parallelize(workers: :number_of_processors, with: :processes, threshold: Act Minitest.parallel_executor = ActiveSupport::Testing::ParallelizeExecutor.new(size: workers, with: with, threshold: threshold) end - # Set up hook for parallel testing. This can be used if you have multiple + # Before fork hook for parallel testing. This can be used to run anything + # before the processes are forked. + # + # In your +test_helper.rb+ add the following: + # + # class ActiveSupport::TestCase + # parallelize_before_fork do + # # run this before fork + # end + # end + def parallelize_before_fork(&block) + ActiveSupport::Testing::Parallelization.before_fork_hook(&block) + end + + # Setup hook for parallel testing. This can be used if you have multiple # databases or any behavior that needs to be run after the process is forked # but before the tests run. # From cb07bf9c5a9a63b7b00a6079bb1b88a0e2f203ac Mon Sep 17 00:00:00 2001 From: Alessandro Dal Grande Date: Thu, 1 May 2025 10:19:44 -0700 Subject: [PATCH 0138/1075] Rescue connection related errors in MemCacheStore#read_multi_entries --- .../active_support/cache/mem_cache_store.rb | 26 +++++++++---------- .../test/cache/stores/mem_cache_store_test.rb | 24 +++++++++++++++++ 2 files changed, 36 insertions(+), 14 deletions(-) diff --git a/activesupport/lib/active_support/cache/mem_cache_store.rb b/activesupport/lib/active_support/cache/mem_cache_store.rb index 5f4792dfa73e2..40fe48e1bafd7 100644 --- a/activesupport/lib/active_support/cache/mem_cache_store.rb +++ b/activesupport/lib/active_support/cache/mem_cache_store.rb @@ -219,26 +219,24 @@ def write_serialized_entry(key, payload, **options) def read_multi_entries(names, **options) keys_to_names = names.index_by { |name| normalize_key(name, options) } - raw_values = begin - @data.with { |c| c.get_multi(keys_to_names.keys) } - rescue Dalli::UnmarshalError - {} - end + rescue_error_with({}) do + raw_values = @data.with { |c| c.get_multi(keys_to_names.keys) } - values = {} + values = {} - raw_values.each do |key, value| - entry = deserialize_entry(value, raw: options[:raw]) + raw_values.each do |key, value| + entry = deserialize_entry(value, raw: options[:raw]) - unless entry.nil? || entry.expired? || entry.mismatched?(normalize_version(keys_to_names[key], options)) - begin - values[keys_to_names[key]] = entry.value - rescue DeserializationError + unless entry.nil? || entry.expired? || entry.mismatched?(normalize_version(keys_to_names[key], options)) + begin + values[keys_to_names[key]] = entry.value + rescue DeserializationError + end end end - end - values + values + end end # Delete an entry from the cache. diff --git a/activesupport/test/cache/stores/mem_cache_store_test.rb b/activesupport/test/cache/stores/mem_cache_store_test.rb index 976225bb3da4d..7b8d69a298e41 100644 --- a/activesupport/test/cache/stores/mem_cache_store_test.rb +++ b/activesupport/test/cache/stores/mem_cache_store_test.rb @@ -371,6 +371,30 @@ def test_can_read_multi_entries_raw_values_from_dalli_store assert_equal({}, @cache.send(:read_multi_entries, [key])) end + def test_falls_back_to_default_value_when_client_raises_dalli_error + cache = lookup_store + client = cache.instance_variable_get(:@data) + client.stub(:get_multi, lambda { |*_args| raise Dalli::DalliError.new("test error") }) do + assert_equal({}, cache.read_multi("key1", "key2")) + end + end + + def test_falls_back_to_default_value_when_client_raises_connection_pool_timeout_error + cache = lookup_store + client = cache.instance_variable_get(:@data) + client.stub(:get_multi, lambda { |*_args| raise ConnectionPool::TimeoutError.new("test error") }) do + assert_equal({}, cache.read_multi("key1", "key2")) + end + end + + def test_falls_back_to_default_value_when_client_raises_connection_pool_error + cache = lookup_store + client = cache.instance_variable_get(:@data) + client.stub(:get_multi, lambda { |*_args| raise ConnectionPool::Error.new("test error") }) do + assert_equal({}, cache.read_multi("key1", "key2")) + end + end + def test_pool_options_work cache = ActiveSupport::Cache.lookup_store(:mem_cache_store, pool: { size: 2, timeout: 1 }) pool = cache.instance_variable_get(:@data) # loads 'connection_pool' gem From 9253e6e06777d526bbb6adcdd1af836f4efc8225 Mon Sep 17 00:00:00 2001 From: Alexander Momchilov Date: Fri, 2 May 2025 17:25:27 -0400 Subject: [PATCH 0139/1075] Improve `#report` backtrace tests --- activesupport/test/error_reporter_test.rb | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/activesupport/test/error_reporter_test.rb b/activesupport/test/error_reporter_test.rb index 8d904a29bd357..01b302c3e723b 100644 --- a/activesupport/test/error_reporter_test.rb +++ b/activesupport/test/error_reporter_test.rb @@ -166,9 +166,10 @@ class ErrorReporterTest < ActiveSupport::TestCase assert_nil error.backtrace assert_nil error.backtrace_locations - assert_nil @reporter.report(error) - assert_not_predicate error.backtrace, :empty? - assert_not_predicate error.backtrace_locations, :empty? + @reporter.report(error) + + assert error.backtrace.first.start_with?(__FILE__) + assert_equal __FILE__, error.backtrace_locations.first.path end test "#record passes through the return value" do From 8819ab5ba521ed93504a0797e2a5ef74081e4c0c Mon Sep 17 00:00:00 2001 From: Yasuo Honda Date: Sat, 3 May 2025 08:03:08 +0900 Subject: [PATCH 0140/1075] Support selenium-webdriver 4.32.0 This pull request supports selenium-webdriver 4.32.0 that sets remote.active-protocols => 1 via https://github.com/SeleniumHQ/selenium/commit/a1ff120a9fd - Steps to reproduce and this commit fixes these two failures: ``` $ bundle update selenium-webdriver --conservative ... snip ... $ git diff diff --git a/Gemfile.lock b/Gemfile.lock index db3f0d1517..07f40b95f5 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -574,7 +574,7 @@ GEM sass-embedded (1.83.4-x86_64-linux-musl) google-protobuf (~> 4.29) securerandom (0.4.1) - selenium-webdriver (4.29.1) + selenium-webdriver (4.32.0) base64 (~> 0.2) logger (~> 1.4) rexml (~> 3.2, >= 3.2.5) $ bin/test test/dispatch/system_testing/driver_test.rb -n '/^test_define_extra_capabilities_using_(headless_)?firefox$/' ... snip ... F Failure: DriverTest#test_define_extra_capabilities_using_firefox [test/dispatch/system_testing/driver_test.rb:128]: --- expected +++ actual @@ -1 +1 @@ -{"moz:firefoxOptions" => {"args" => ["--host=127.0.0.1"], "prefs" => {"remote.active-protocols" => 3, "browser.startup.homepage" => "http://www.seleniumhq.com/"}}, "browserName" => "firefox"} +{"moz:firefoxOptions" => {"args" => ["--host=127.0.0.1"], "prefs" => {"remote.active-protocols" => 1, "browser.startup.homepage" => "http://www.seleniumhq.com/"}}, "browserName" => "firefox"} bin/test test/dispatch/system_testing/driver_test.rb:114 F Failure: DriverTest#test_define_extra_capabilities_using_headless_firefox [test/dispatch/system_testing/driver_test.rb:145]: --- expected +++ actual @@ -1 +1 @@ -{"moz:firefoxOptions" => {"args" => ["-headless", "--host=127.0.0.1"], "prefs" => {"remote.active-protocols" => 3, "browser.startup.homepage" => "http://www.seleniumhq.com/"}}, "browserName" => "firefox"} +{"moz:firefoxOptions" => {"args" => ["-headless", "--host=127.0.0.1"], "prefs" => {"remote.active-protocols" => 1, "browser.startup.homepage" => "http://www.seleniumhq.com/"}}, "browserName" => "firefox"} bin/test test/dispatch/system_testing/driver_test.rb:131 Finished in 0.009594s, 208.4566 runs/s, 208.4566 assertions/s. 2 runs, 2 assertions, 2 failures, 0 errors, 0 skips $ ``` --- Gemfile.lock | 2 +- actionpack/test/dispatch/system_testing/driver_test.rb | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index db3f0d151785e..07f40b95f5a60 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -574,7 +574,7 @@ GEM sass-embedded (1.83.4-x86_64-linux-musl) google-protobuf (~> 4.29) securerandom (0.4.1) - selenium-webdriver (4.29.1) + selenium-webdriver (4.32.0) base64 (~> 0.2) logger (~> 1.4) rexml (~> 3.2, >= 3.2.5) diff --git a/actionpack/test/dispatch/system_testing/driver_test.rb b/actionpack/test/dispatch/system_testing/driver_test.rb index fa57a93678286..c828c17d6957e 100644 --- a/actionpack/test/dispatch/system_testing/driver_test.rb +++ b/actionpack/test/dispatch/system_testing/driver_test.rb @@ -121,7 +121,7 @@ class DriverTest < ActiveSupport::TestCase expected = { "moz:firefoxOptions" => { "args" => ["--host=127.0.0.1"], - "prefs" => { "remote.active-protocols" => 3, "browser.startup.homepage" => "http://www.seleniumhq.com/" } + "prefs" => { "remote.active-protocols" => 1, "browser.startup.homepage" => "http://www.seleniumhq.com/" } }, "browserName" => "firefox" } @@ -138,7 +138,7 @@ class DriverTest < ActiveSupport::TestCase expected = { "moz:firefoxOptions" => { "args" => ["-headless", "--host=127.0.0.1"], - "prefs" => { "remote.active-protocols" => 3, "browser.startup.homepage" => "http://www.seleniumhq.com/" } + "prefs" => { "remote.active-protocols" => 1, "browser.startup.homepage" => "http://www.seleniumhq.com/" } }, "browserName" => "firefox" } From 641d9eb6d7ffe7f9b6a2e1440a5227586bcb237c Mon Sep 17 00:00:00 2001 From: Yasuo Honda Date: Fri, 25 Apr 2025 15:39:11 +0900 Subject: [PATCH 0141/1075] Skip Sidekiq 8.0.3 This commit skips Sidekiq 8.0.3 until https://github.com/sidekiq/sidekiq/issues/6683 is resolved. --- Gemfile | 2 +- Gemfile.lock | 15 ++++++++------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/Gemfile b/Gemfile index 180be0482251e..6512ce889c746 100644 --- a/Gemfile +++ b/Gemfile @@ -100,7 +100,7 @@ gem "useragent", require: false group :job do gem "resque", require: false gem "resque-scheduler", require: false - gem "sidekiq", require: false + gem "sidekiq", "!= 8.0.3", require: false gem "sucker_punch", require: false gem "queue_classic", ">= 4.0.0", require: false, platforms: :ruby gem "sneakers", require: false diff --git a/Gemfile.lock b/Gemfile.lock index 07f40b95f5a60..18c11e9bf3d3a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -484,7 +484,7 @@ GEM redcarpet (3.6.1) redis (5.3.0) redis-client (>= 0.22.0) - redis-client (0.23.1) + redis-client (0.24.0) connection_pool redis-namespace (1.11.0) redis (>= 4) @@ -583,11 +583,12 @@ GEM serverengine (2.0.7) sigdump (~> 0.2.2) set (1.1.1) - sidekiq (7.3.7) - connection_pool (>= 2.3.0) - logger - rack (>= 2.2.4) - redis-client (>= 0.22.2) + sidekiq (8.0.2) + connection_pool (>= 2.5.0) + json (>= 2.9.0) + logger (>= 1.6.2) + rack (>= 3.1.0) + redis-client (>= 0.23.2) sigdump (0.2.5) signet (0.19.0) addressable (~> 2.8) @@ -790,7 +791,7 @@ DEPENDENCIES rubyzip (~> 2.0) sdoc! selenium-webdriver (>= 4.20.0) - sidekiq + sidekiq (!= 8.0.3) sneakers solid_cable solid_cache From 6673822c5190e37a9b9c82737cf27ba97118ca83 Mon Sep 17 00:00:00 2001 From: Yasuo Honda Date: Sun, 4 May 2025 11:12:16 +0000 Subject: [PATCH 0142/1075] Fix "ERROR [launcher.sauce]: Can not start safari latest" This commit addresses the following error in the Action Cable integration test: https://buildkite.com/rails/rails/builds/118358#01969a38-6324-4e5f-9ced-bff4b8b87599 ``` 04 05 2025 07:36:27.081:ERROR [launcher.sauce]: Can not start safari latest ... snip ... UnsupportedPlatform - no images found for the specified caps ``` According to https://saucelabs.com/products/platform-configurator, macOS Sonoma 14 is currently in beta on SauceLabs. Therefore, this change uses macOS 13 Ventura and specifies Safari version 16, which is the default Safari version for macOS 13 Ventura. --- actioncable/karma.conf.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/actioncable/karma.conf.js b/actioncable/karma.conf.js index a6d370788f9cb..bb16f969546d1 100644 --- a/actioncable/karma.conf.js +++ b/actioncable/karma.conf.js @@ -25,7 +25,7 @@ if (process.env.CI) { config.customLaunchers = { sl_chrome: sauce("chrome", 70), sl_ff: sauce("firefox", 63), - sl_safari: sauce("safari", "latest"), + sl_safari: sauce("safari", "16", "macOS 13"), sl_edge: sauce("microsoftedge", 17.17134, "Windows 10"), } From 576db3bbd4446a59f46a2d7d2b2afa78898d2fd8 Mon Sep 17 00:00:00 2001 From: Hartley McGuire Date: Sun, 4 May 2025 14:37:21 -0400 Subject: [PATCH 0143/1075] Use TRUE and FALSE for more SQLite queries A [previous commit][0] updated the SQLite Arel visitor to use `TRUE` and `FALSE` literals instead of `1` and `0` for `True` and `False` Arel Nodes. This commit takes the change further by making SQLite quote `true` and `false` values as `TRUE` and `FALSE` instead of `1` and `0`. This additionally required adding support for `DEFAULT TRUE` and `DEFAULT FALSE` SQLite column definitions (which is more correct anyways in case someone made `TRUE` or `FALSE` the default value of a column using `default: -> { "TRUE" }`, etc). [0]: 34bebf383e18243a1cdadc461e3a84c66125cb9b --- .../active_record/connection_adapters/sqlite3/quoting.rb | 8 -------- .../active_record/connection_adapters/sqlite3_adapter.rb | 2 ++ .../test/cases/adapters/sqlite3/bind_parameter_test.rb | 2 +- 3 files changed, 3 insertions(+), 9 deletions(-) diff --git a/activerecord/lib/active_record/connection_adapters/sqlite3/quoting.rb b/activerecord/lib/active_record/connection_adapters/sqlite3/quoting.rb index 0aaef16b5f2a5..d9a681ad94329 100644 --- a/activerecord/lib/active_record/connection_adapters/sqlite3/quoting.rb +++ b/activerecord/lib/active_record/connection_adapters/sqlite3/quoting.rb @@ -80,18 +80,10 @@ def quoted_binary(value) "x'#{value.hex}'" end - def quoted_true - "1" - end - def unquoted_true 1 end - def quoted_false - "0" - end - def unquoted_false 0 end diff --git a/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb b/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb index be089cc416669..aef0388e3f878 100644 --- a/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb @@ -564,6 +564,8 @@ def extract_value_from_default(default) # Binary columns when /x'(.*)'/ [ $1 ].pack("H*") + when "TRUE", "FALSE" + default else # Anything else is blank or some function # and we can't know the value of that, so return nil. diff --git a/activerecord/test/cases/adapters/sqlite3/bind_parameter_test.rb b/activerecord/test/cases/adapters/sqlite3/bind_parameter_test.rb index 20c8d62d8ff94..a26dcef0a9e27 100644 --- a/activerecord/test/cases/adapters/sqlite3/bind_parameter_test.rb +++ b/activerecord/test/cases/adapters/sqlite3/bind_parameter_test.rb @@ -22,7 +22,7 @@ def test_where_with_float_for_string_column_using_bind_parameters end def test_where_with_boolean_for_string_column_using_bind_parameters - assert_quoted_as "0", false + assert_quoted_as "FALSE", false end def test_where_with_decimal_for_string_column_using_bind_parameters From 423dcce5b6dd203b280cfd822b4564f6efbe0904 Mon Sep 17 00:00:00 2001 From: Will Roever Date: Thu, 1 May 2025 19:50:15 -0700 Subject: [PATCH 0144/1075] Defer ActiveJob enqueue callbacks until after commit when enqueue_after_transaction_commit enabled --- activejob/CHANGELOG.md | 5 ++++ activejob/lib/active_job/enqueuing.rb | 10 +++++--- .../enqueue_after_transaction_commit_test.rb | 25 +++++++++++++++++++ 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/activejob/CHANGELOG.md b/activejob/CHANGELOG.md index 16ea3d7ff93fc..c1e3c8d15df11 100644 --- a/activejob/CHANGELOG.md +++ b/activejob/CHANGELOG.md @@ -1,3 +1,8 @@ +* Defer invocation of ActiveJob enqueue callbacks until after commit when + `enqueue_after_transaction_commit` is enabled. + + *Will Roever* + * Add `report:` option to `ActiveJob::Base#retry_on` and `#discard_on` When the `report:` option is passed, errors will be reported to the error reporter diff --git a/activejob/lib/active_job/enqueuing.rb b/activejob/lib/active_job/enqueuing.rb index bcb7786950887..e8a725101f943 100644 --- a/activejob/lib/active_job/enqueuing.rb +++ b/activejob/lib/active_job/enqueuing.rb @@ -113,9 +113,7 @@ def enqueue(options = {}) set(options) self.successfully_enqueued = false - run_callbacks :enqueue do - raw_enqueue - end + raw_enqueue if successfully_enqueued? self @@ -126,6 +124,12 @@ def enqueue(options = {}) private def raw_enqueue + run_callbacks :enqueue do + _raw_enqueue + end + end + + def _raw_enqueue if scheduled_at queue_adapter.enqueue_at self, scheduled_at.to_f else diff --git a/activejob/test/cases/enqueue_after_transaction_commit_test.rb b/activejob/test/cases/enqueue_after_transaction_commit_test.rb index 0856aabfed25b..1a07fb3a7d97e 100644 --- a/activejob/test/cases/enqueue_after_transaction_commit_test.rb +++ b/activejob/test/cases/enqueue_after_transaction_commit_test.rb @@ -55,6 +55,21 @@ def perform end end + class EnqueueAfterCommitCallbackJob < ActiveJob::Base + self.enqueue_after_transaction_commit = true + + attr_reader :around_enqueue_called + + around_enqueue do |job, block| + job.instance_variable_set(:@around_enqueue_called, true) + block.call + end + + def perform + # noop + end + end + test "#perform_later wait for transactions to complete before enqueuing the job" do fake_active_record = FakeActiveRecord.new stub_const(Object, :ActiveRecord, fake_active_record, exists: false) do @@ -98,4 +113,14 @@ def perform assert_not_predicate job, :successfully_enqueued? end end + + test "#perform_later defers enqueue callbacks until after commit" do + fake_active_record = FakeActiveRecord.new(false) + stub_const(Object, :ActiveRecord, fake_active_record, exists: false) do + job = EnqueueAfterCommitCallbackJob.perform_later + assert_not_predicate job, :around_enqueue_called + fake_active_record.run_after_commit_callbacks + assert_predicate job, :around_enqueue_called + end + end end From 04358efad1bbd12a20519f1d0e3a93c6bfc30e3e Mon Sep 17 00:00:00 2001 From: Xavier Noria Date: Sat, 3 May 2025 20:03:19 +0200 Subject: [PATCH 0145/1075] Make the executor hooks in AR::QueryCache private --- activerecord/lib/active_record/query_cache.rb | 37 +++++++++++-------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/activerecord/lib/active_record/query_cache.rb b/activerecord/lib/active_record/query_cache.rb index 3a4e49e8a17f4..7d91c5704f012 100644 --- a/activerecord/lib/active_record/query_cache.rb +++ b/activerecord/lib/active_record/query_cache.rb @@ -3,6 +3,7 @@ module ActiveRecord # = Active Record Query Cache class QueryCache + # ActiveRecord::Base extends this module, so these methods are available in models. module ClassMethods # Enable the query cache within the block if Active Record is configured. # If it's not, it will execute the given block. @@ -20,11 +21,15 @@ def cache(&block) end end - # Disable the query cache within the block if Active Record is configured. - # If it's not, it will execute the given block. + # Runs the block with the query cache disabled. + # + # If the query cache was enabled before the block was executed, it is + # enabled again after it. # - # Set dirties: false to prevent query caches on all connections from being cleared by write operations. - # (By default, write operations dirty all connections' query caches in case they are replicas whose cache would now be outdated.) + # Set dirties: false to prevent query caches on all connections + # from being cleared by write operations. (By default, write operations + # dirty all connections' query caches in case they are replicas whose + # cache would now be outdated.) def uncached(dirties: true, &block) if connected? || !configurations.empty? connection_pool.disable_query_cache(dirties: dirties, &block) @@ -34,22 +39,24 @@ def uncached(dirties: true, &block) end end - def self.run - ActiveRecord::Base.connection_handler.each_connection_pool.reject(&:query_cache_enabled).each do |pool| - next if pool.db_config&.query_cache == false - pool.enable_query_cache! + module ExecutorHooks # :nodoc: + def self.run + ActiveRecord::Base.connection_handler.each_connection_pool.reject(&:query_cache_enabled).each do |pool| + next if pool.db_config&.query_cache == false + pool.enable_query_cache! + end end - end - def self.complete(pools) - pools.each do |pool| - pool.disable_query_cache! - pool.clear_query_cache + def self.complete(pools) + pools.each do |pool| + pool.disable_query_cache! + pool.clear_query_cache + end end end - def self.install_executor_hooks(executor = ActiveSupport::Executor) - executor.register_hook(self) + def self.install_executor_hooks(executor = ActiveSupport::Executor) # :nodoc: + executor.register_hook(ExecutorHooks) end end end From 2797898421879eee86e35588529de56255474be4 Mon Sep 17 00:00:00 2001 From: Jatin Goyal Date: Mon, 5 May 2025 19:40:25 +0530 Subject: [PATCH 0146/1075] Update guides on usage of --skip-solid flag to reflect latest information --- guides/source/caching_with_rails.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/guides/source/caching_with_rails.md b/guides/source/caching_with_rails.md index 4ccc5e902aec3..0f5c5bbb383d5 100644 --- a/guides/source/caching_with_rails.md +++ b/guides/source/caching_with_rails.md @@ -396,9 +396,13 @@ you'd prefer not to utilize it, you can skip Solid Cache: rails new app_name --skip-solid ``` -WARNING: Both Solid Cache and Solid Queue are bundled behind the `--skip-solid` -flag. If you still want to use Solid Queue but not Solid Cache, you can enable -Solid Queue by running `bin/rails app:enable-solid-queue`. +WARNING: All parts of the Solid Trifecta (Solid Cache, Solid Queue and Solid +Cable) are bundled behind the `--skip-solid` flag. If you still want to use +Solid Queue and Solid Cable but not Solid Cache, you can install them +separately by following [Solid Queue +Installation](https://github.com/rails/solid_queue#installation) and +[Solid Cable Installation](https://github.com/rails/solid_cable#installation) +respectively. ### Configuring the Database From f9a7ef84835fc46c2319607b3023441b89746926 Mon Sep 17 00:00:00 2001 From: Alexander Momchilov Date: Fri, 2 May 2025 17:27:16 -0400 Subject: [PATCH 0147/1075] Test that `#report` sets the `cause` --- activesupport/lib/active_support/error_reporter.rb | 8 +++----- activesupport/test/error_reporter_test.rb | 11 +++++++++++ 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/activesupport/lib/active_support/error_reporter.rb b/activesupport/lib/active_support/error_reporter.rb index dfcf486949406..68559f062f01b 100644 --- a/activesupport/lib/active_support/error_reporter.rb +++ b/activesupport/lib/active_support/error_reporter.rb @@ -276,14 +276,12 @@ def report(error, handled: true, severity: handled ? :warning : :error, context: private def ensure_backtrace(error) - return if error.frozen? # re-raising won't add a backtrace + return if error.frozen? # re-raising won't add a backtrace or set the cause return unless error.backtrace.nil? begin - # We could use Exception#set_backtrace, but until Ruby 3.4 - # it only support setting `Exception#backtrace` and not - # `Exception#backtrace_locations`. So raising the exception - # is a good way to build a real backtrace. + # As of Ruby 3.4, we could use Exception#set_backtrace to set the backtrace, + # but there's nothing like Exception#set_cause. Raising+rescuing is the only way to set the cause. raise error rescue error.class => error end diff --git a/activesupport/test/error_reporter_test.rb b/activesupport/test/error_reporter_test.rb index 01b302c3e723b..af1f7c192205c 100644 --- a/activesupport/test/error_reporter_test.rb +++ b/activesupport/test/error_reporter_test.rb @@ -172,6 +172,17 @@ class ErrorReporterTest < ActiveSupport::TestCase assert_equal __FILE__, error.backtrace_locations.first.path end + test "#report assigns a cause if it's missing" do + raise "the original cause" + rescue => cause + new_error = StandardError.new("A new error that should wrap the StandardError") + assert_nil new_error.cause + + @reporter.report(new_error) + + assert_same cause, new_error.cause + end + test "#record passes through the return value" do result = @reporter.record do 2 + 2 From a2928dd9d987ce76609811904c4e3cb18abace9e Mon Sep 17 00:00:00 2001 From: Hartley McGuire Date: Sun, 20 Apr 2025 16:00:53 -0400 Subject: [PATCH 0148/1075] Enable passing retryable `SqlLiteral`s to `#where` Previously, retryable SqlLiterals passed to `#where` would lose their retryability because both `#build_where_clause` and `WhereClause` would wrap them in non-retryable `SqlLiteral`s. To fix this problem, this commit updates `#build_where_clause` to check for `SqlLiterals` and updates `WhereClause` to assume that any predicate passed in will already be wrapped. There were only a few places that passed Strings to `WhereClause` (`"1=0"` in `PredicateBuilder` and sanitized sql strings in `#build_where_clause`) that needed to be updated to use `Arel.sql`. The `WhereClause` tests also had Strings to wrap but they are really unit tests and aren't representative of what can be done with the public API --- activerecord/CHANGELOG.md | 4 +++ .../relation/predicate_builder.rb | 2 +- .../active_record/relation/query_methods.rb | 8 +++-- .../active_record/relation/where_clause.rb | 9 +----- activerecord/test/cases/adapter_test.rb | 6 ++-- .../test/cases/relation/where_clause_test.rb | 30 +++++++++---------- 6 files changed, 31 insertions(+), 28 deletions(-) diff --git a/activerecord/CHANGELOG.md b/activerecord/CHANGELOG.md index 5212170bec45f..e37f23f049a76 100644 --- a/activerecord/CHANGELOG.md +++ b/activerecord/CHANGELOG.md @@ -1,3 +1,7 @@ +* Enable passing retryable SqlLiterals to `#where`. + + *Hartley McGuire* + * Set default for primary keys in `insert_all`/`upsert_all`. Previously in Postgres, updating and inserting new records in one upsert wasn't possible diff --git a/activerecord/lib/active_record/relation/predicate_builder.rb b/activerecord/lib/active_record/relation/predicate_builder.rb index 8d53e80069789..6600ac9ebe3ff 100644 --- a/activerecord/lib/active_record/relation/predicate_builder.rb +++ b/activerecord/lib/active_record/relation/predicate_builder.rb @@ -82,7 +82,7 @@ def with(table) attr_writer :table def expand_from_hash(attributes, &block) - return ["1=0"] if attributes.empty? + return [Arel.sql("1=0", retryable: true)] if attributes.empty? attributes.flat_map do |key, value| if key.is_a?(Array) && key.size == 1 diff --git a/activerecord/lib/active_record/relation/query_methods.rb b/activerecord/lib/active_record/relation/query_methods.rb index 29bfb6cdf2792..5fbc64e98cea6 100644 --- a/activerecord/lib/active_record/relation/query_methods.rb +++ b/activerecord/lib/active_record/relation/query_methods.rb @@ -1624,13 +1624,17 @@ def build_where_clause(opts, rest = []) # :nodoc: case opts when String if rest.empty? - parts = [Arel.sql(opts)] + if Arel.arel_node?(opts) + parts = [opts] + else + parts = [Arel.sql(opts)] + end elsif rest.first.is_a?(Hash) && /:\w+/.match?(opts) parts = [build_named_bound_sql_literal(opts, rest.first)] elsif opts.include?("?") parts = [build_bound_sql_literal(opts, rest)] else - parts = [model.sanitize_sql(rest.empty? ? opts : [opts, *rest])] + parts = [Arel.sql(model.sanitize_sql([opts, *rest]))] end when Hash opts = opts.transform_keys do |key| diff --git a/activerecord/lib/active_record/relation/where_clause.rb b/activerecord/lib/active_record/relation/where_clause.rb index 3da4198836aec..042977b8dafbe 100644 --- a/activerecord/lib/active_record/relation/where_clause.rb +++ b/activerecord/lib/active_record/relation/where_clause.rb @@ -188,7 +188,7 @@ def predicates_with_wrapped_sql_literals non_empty_predicates.map do |node| case node when Arel::Nodes::SqlLiteral, ::String - wrap_sql_literal(node) + Arel::Nodes::Grouping.new(node) else node end end @@ -199,13 +199,6 @@ def non_empty_predicates predicates - ARRAY_WITH_EMPTY_STRING end - def wrap_sql_literal(node) - if ::String === node - node = Arel.sql(node) - end - Arel::Nodes::Grouping.new(node) - end - def extract_node_value(node) if node.respond_to?(:value_before_type_cast) node.value_before_type_cast diff --git a/activerecord/test/cases/adapter_test.rb b/activerecord/test/cases/adapter_test.rb index e41361d85c39f..09d640de2cf06 100644 --- a/activerecord/test/cases/adapter_test.rb +++ b/activerecord/test/cases/adapter_test.rb @@ -706,6 +706,7 @@ def teardown notifications = capture_notifications("sql.active_record") do assert (a = Author.first) assert Post.where(id: [1, 2]).first + assert Post.where(Arel.sql("id IN (1,2)", retryable: true)).first assert Post.find(1) assert Post.find_by(title: "Welcome to the weblog") assert_predicate Post, :exists? @@ -714,7 +715,7 @@ def teardown Author.group(:name).count end.select { |n| n.payload[:name] != "SCHEMA" } - assert_equal 8, notifications.length + assert_equal 9, notifications.length notifications.each do |n| assert n.payload[:allow_retry], "#{n.payload[:sql]} was not retryable" @@ -727,6 +728,7 @@ def teardown notifications = capture_notifications("sql.active_record") do assert_not_nil (a = Author.first) assert_not_nil Post.where(id: [1, 2]).first + assert Post.where(Arel.sql("id IN (1,2)", retryable: true)).first assert_not_nil Post.find(1) assert_not_nil Post.find_by(title: "Welcome to the weblog") assert_predicate Post, :exists? @@ -735,7 +737,7 @@ def teardown Author.group(:name).count end.select { |n| n.payload[:name] != "SCHEMA" } - assert_equal 8, notifications.length + assert_equal 9, notifications.length notifications.each do |n| assert n.payload[:allow_retry], "#{n.payload[:sql]} was not retryable" diff --git a/activerecord/test/cases/relation/where_clause_test.rb b/activerecord/test/cases/relation/where_clause_test.rb index f8ee81f481d23..2fa31386edac4 100644 --- a/activerecord/test/cases/relation/where_clause_test.rb +++ b/activerecord/test/cases/relation/where_clause_test.rb @@ -15,9 +15,9 @@ class WhereClauseTest < ActiveRecord::TestCase end test "+ is associative, but not commutative" do - a = WhereClause.new(["a"]) - b = WhereClause.new(["b"]) - c = WhereClause.new(["c"]) + a = WhereClause.new([Arel.sql("a")]) + b = WhereClause.new([Arel.sql("b")]) + c = WhereClause.new([Arel.sql("c")]) assert_equal a + (b + c), (a + b) + c assert_not_equal a + b, b + a @@ -76,7 +76,7 @@ class WhereClauseTest < ActiveRecord::TestCase test "a clause knows if it is empty" do assert_empty WhereClause.empty - assert_not_empty WhereClause.new(["anything"]) + assert_not_empty WhereClause.new([Arel.sql("anything")]) end test "invert cannot handle nil" do @@ -99,7 +99,7 @@ class WhereClauseTest < ActiveRecord::TestCase table["id"].lteq(2), table["id"].is_not_distinct_from(1), table["id"].is_distinct_from(2), - "sql literal" + Arel.sql("sql literal"), ]) expected = WhereClause.new([ Arel::Nodes::Not.new( @@ -114,7 +114,7 @@ class WhereClauseTest < ActiveRecord::TestCase table["id"].lteq(2), table["id"].is_not_distinct_from(1), table["id"].is_distinct_from(2), - Arel::Nodes::Grouping.new("sql literal") + Arel::Nodes::Grouping.new(Arel.sql("sql literal")), ]) ) ]) @@ -161,7 +161,7 @@ class WhereClauseTest < ActiveRecord::TestCase random_object = Object.new where_clause = WhereClause.new([ table["id"].in([1, 2, 3]), - "foo = bar", + Arel.sql("foo = bar"), random_object, ]) expected = Arel::Nodes::And.new([ @@ -175,7 +175,7 @@ class WhereClauseTest < ActiveRecord::TestCase test "ast removes any empty strings" do where_clause = WhereClause.new([table["id"].in([1, 2, 3])]) - where_clause_with_empty = WhereClause.new([table["id"].in([1, 2, 3]), ""]) + where_clause_with_empty = WhereClause.new([table["id"].in([1, 2, 3]), Arel.sql("")]) assert_equal where_clause.ast, where_clause_with_empty.ast end @@ -232,12 +232,12 @@ class WhereClauseTest < ActiveRecord::TestCase test "or will use only common conditions if one side only has common conditions" do only_common = WhereClause.new([ table["id"].eq(bind_param(1)), - "foo = bar", + Arel.sql("foo = bar"), ]) common_with_extra = WhereClause.new([ table["id"].eq(bind_param(1)), - "foo = bar", + Arel.sql("foo = bar"), table["extra"].eq(bind_param("pluto")), ]) @@ -247,13 +247,13 @@ class WhereClauseTest < ActiveRecord::TestCase test "supports hash equality" do h = Hash.new(0) - h[WhereClause.new(["a"])] += 1 - h[WhereClause.new(["a"])] += 1 - h[WhereClause.new(["b"])] += 1 + h[WhereClause.new([Arel.sql("a")])] += 1 + h[WhereClause.new([Arel.sql("a")])] += 1 + h[WhereClause.new([Arel.sql("b")])] += 1 expected = { - WhereClause.new(["a"]) => 2, - WhereClause.new(["b"]) => 1 + WhereClause.new([Arel.sql("a")]) => 2, + WhereClause.new([Arel.sql("b")]) => 1 } assert_equal expected, h end From f3763e608a6e6d0e56d015554809c83a68c5d918 Mon Sep 17 00:00:00 2001 From: Hartley McGuire Date: Mon, 5 May 2025 18:07:20 -0400 Subject: [PATCH 0149/1075] Remove SqlLiteral re-wrapping from Arel.sql Previously, passing a SqlLiteral to Arel.sql would wrap the SqlLiteral in a new SqlLiteral (which is generally unnecessary since it allocates a new SqlLiteral/String). This commit updates Arel.sql to no longer perform the wrapping of the given "sql_string" is already a SqlLiteral. --- activerecord/lib/active_record/relation/query_methods.rb | 6 +----- activerecord/lib/arel.rb | 4 +++- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/activerecord/lib/active_record/relation/query_methods.rb b/activerecord/lib/active_record/relation/query_methods.rb index 5fbc64e98cea6..abb796390a75a 100644 --- a/activerecord/lib/active_record/relation/query_methods.rb +++ b/activerecord/lib/active_record/relation/query_methods.rb @@ -1624,11 +1624,7 @@ def build_where_clause(opts, rest = []) # :nodoc: case opts when String if rest.empty? - if Arel.arel_node?(opts) - parts = [opts] - else - parts = [Arel.sql(opts)] - end + parts = [Arel.sql(opts)] elsif rest.first.is_a?(Hash) && /:\w+/.match?(opts) parts = [build_named_bound_sql_literal(opts, rest.first)] elsif opts.include?("?") diff --git a/activerecord/lib/arel.rb b/activerecord/lib/arel.rb index 738e80df359fd..27a7d79126dc6 100644 --- a/activerecord/lib/arel.rb +++ b/activerecord/lib/arel.rb @@ -50,7 +50,9 @@ module Arel # Use this option only if the SQL is idempotent, as it could be executed # more than once. def self.sql(sql_string, *positional_binds, retryable: false, **named_binds) - if positional_binds.empty? && named_binds.empty? + if Arel::Nodes::SqlLiteral === sql_string + sql_string + elsif positional_binds.empty? && named_binds.empty? Arel::Nodes::SqlLiteral.new(sql_string, retryable: retryable) else Arel::Nodes::BoundSqlLiteral.new sql_string, positional_binds, named_binds From d81d41dc44cc578a32cb0e83c8e82032559baa26 Mon Sep 17 00:00:00 2001 From: Matheus Oliveira Date: Tue, 6 May 2025 21:21:31 -0300 Subject: [PATCH 0150/1075] Update documentation for `create_join_table` Follow up of #28217. `column_options` must not be sent inside `options` anymore. Also, add an example about how to use delete cascade. --- .../abstract/schema_statements.rb | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) 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 9989afd85dc24..9579b8e5aa003 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb @@ -354,11 +354,13 @@ def build_create_table_definition(table_name, id: :primary_key, primary_key: nil # # Creates a table called 'music_artists_records' with no id. # create_join_table('music_artists', 'music_records') # + # See {connection.add_reference}[rdoc-ref:SchemaStatements#add_reference] + # for details of the options you can use in +column_options+. +column_options+ + # will be applied to both columns. + # # You can pass an +options+ hash which can include the following keys: # [:table_name] # Sets the table name, overriding the default. - # [:column_options] - # Any extra options you want appended to the columns definition. # [:options] # Any extra options you want appended to the table definition. # [:temporary] @@ -375,6 +377,19 @@ def build_create_table_definition(table_name, id: :primary_key, primary_key: nil # t.index :category_id # end # + # ====== Add foreign keys with delete cascade + # + # create_join_table(:assemblies, :parts, column_options: { foreign_key: { on_delete: :cascade } }) + # + # generates: + # + # CREATE TABLE assemblies_parts ( + # assembly_id bigint NOT NULL, + # part_id bigint NOT NULL, + # CONSTRAINT fk_rails_0d8a572d89 FOREIGN KEY ("assembly_id") REFERENCES "assemblies" ("id") ON DELETE CASCADE, + # CONSTRAINT fk_rails_ec7b48402b FOREIGN KEY ("part_id") REFERENCES "parts" ("id") ON DELETE CASCADE + # ) + # # ====== Add a backend specific option to the generated SQL (MySQL) # # create_join_table(:assemblies, :parts, options: 'ENGINE=InnoDB DEFAULT CHARSET=utf8') From 9c7c5d52df1ad6c5df366e787ae45a333bc05eb0 Mon Sep 17 00:00:00 2001 From: hachi8833 Date: Sat, 10 May 2025 10:50:24 +0900 Subject: [PATCH 0151/1075] [ci-skip][docs] Fix rich_text_area to rich_textarea in getting_started.md --- guides/source/getting_started.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/guides/source/getting_started.md b/guides/source/getting_started.md index 672aa758fcb73..e6455375e0762 100644 --- a/guides/source/getting_started.md +++ b/guides/source/getting_started.md @@ -1756,7 +1756,7 @@ description in `app/views/products/_form.html.erb` before the submit button.
<%= form.label :description, style: "display: block" %> - <%= form.rich_text_area :description %> + <%= form.rich_textarea :description %>
From 6840dd2952094dcb5cfc9f7fcad86c668086f910 Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Sat, 10 May 2025 11:50:53 +0200 Subject: [PATCH 0152/1075] Update psych for ruby-head compatibility --- Gemfile.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 18c11e9bf3d3a..fc30e259f0dd6 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -442,7 +442,7 @@ GEM activesupport (>= 7.0.0) rack railties (>= 7.0.0) - psych (5.2.2) + psych (5.2.5) date stringio public_suffix (6.0.1) @@ -653,7 +653,7 @@ GEM stackprof (0.2.27) stimulus-rails (1.3.4) railties (>= 6.0.0) - stringio (3.1.2) + stringio (3.1.7) sucker_punch (3.2.0) concurrent-ruby (~> 1.0) tailwindcss-rails (3.2.0) From c96bc0f13bc3d45b1e7ec689f1e41966e56c39f7 Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Sat, 10 May 2025 12:22:25 +0200 Subject: [PATCH 0153/1075] Declare dependency on `cgi` It's being (partially) extracted from stdlib in Ruby 3.5 Ref: https://bugs.ruby-lang.org/issues/21258 --- Gemfile.lock | 4 ++++ actionpack/actionpack.gemspec | 1 + actionview/actionview.gemspec | 1 + railties/railties.gemspec | 1 + 4 files changed, 7 insertions(+) diff --git a/Gemfile.lock b/Gemfile.lock index fc30e259f0dd6..c12d3e53ba67e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -34,6 +34,7 @@ PATH actionpack (8.1.0.alpha) actionview (= 8.1.0.alpha) activesupport (= 8.1.0.alpha) + cgi nokogiri (>= 1.8.5) rack (>= 2.2.4) rack-session (>= 1.0.1) @@ -51,6 +52,7 @@ PATH actionview (8.1.0.alpha) activesupport (= 8.1.0.alpha) builder (~> 3.1) + cgi erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) @@ -99,6 +101,7 @@ PATH railties (8.1.0.alpha) actionpack (= 8.1.0.alpha) activesupport (= 8.1.0.alpha) + cgi irb (~> 1.13) rackup (>= 1.0.0) rake (>= 12.2) @@ -177,6 +180,7 @@ GEM rack-test (>= 0.6.3) regexp_parser (>= 1.5, < 3.0) xpath (~> 3.2) + cgi (0.4.2) chef-utils (18.6.2) concurrent-ruby childprocess (5.1.0) diff --git a/actionpack/actionpack.gemspec b/actionpack/actionpack.gemspec index 053897cc7dbcd..bff6cbb4f3a91 100644 --- a/actionpack/actionpack.gemspec +++ b/actionpack/actionpack.gemspec @@ -35,6 +35,7 @@ Gem::Specification.new do |s| s.add_dependency "activesupport", version + s.add_dependency "cgi" s.add_dependency "nokogiri", ">= 1.8.5" s.add_dependency "rack", ">= 2.2.4" s.add_dependency "rack-session", ">= 1.0.1" diff --git a/actionview/actionview.gemspec b/actionview/actionview.gemspec index f090b09969ae1..25a54a4bafc9b 100644 --- a/actionview/actionview.gemspec +++ b/actionview/actionview.gemspec @@ -35,6 +35,7 @@ Gem::Specification.new do |s| s.add_dependency "activesupport", version + s.add_dependency "cgi" s.add_dependency "builder", "~> 3.1" s.add_dependency "erubi", "~> 1.11" s.add_dependency "rails-html-sanitizer", "~> 1.6" diff --git a/railties/railties.gemspec b/railties/railties.gemspec index 918702e24cf3c..de1e112fa10b7 100644 --- a/railties/railties.gemspec +++ b/railties/railties.gemspec @@ -40,6 +40,7 @@ Gem::Specification.new do |s| s.add_dependency "activesupport", version s.add_dependency "actionpack", version + s.add_dependency "cgi" s.add_dependency "rackup", ">= 1.0.0" s.add_dependency "rake", ">= 12.2" s.add_dependency "thor", "~> 1.0", ">= 1.2.2" From 09c5795da28c09f8edc6841c5298e8568afa4d19 Mon Sep 17 00:00:00 2001 From: Andrey Sobolev Date: Sat, 10 May 2025 14:11:16 +0300 Subject: [PATCH 0154/1075] Fix typos in Active Record Encryption guide --- guides/source/active_record_encryption.md | 24 +++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/guides/source/active_record_encryption.md b/guides/source/active_record_encryption.md index f1d3a93bd702b..ef286666400f1 100644 --- a/guides/source/active_record_encryption.md +++ b/guides/source/active_record_encryption.md @@ -82,8 +82,8 @@ There is an important concern about string column sizes: in modern databases the In practice, this means: -* When encrypting short texts written in western alphabets (mostly ASCII characters), you should account for that 255 additional overhead when defining the column size. -* When encrypting short texts written in non-western alphabets, such as Cyrillic, you should multiply the column size by 4. Notice that the storage overhead is 255 bytes at most. +* When encrypting short texts written in Western alphabets (mostly ASCII characters), you should account for that 255 additional overhead when defining the column size. +* When encrypting short texts written in non-Western alphabets, such as Cyrillic, you should multiply the column size by 4. Notice that the storage overhead is 255 bytes at most. * When encrypting long texts, you can ignore column size concerns. Some examples: @@ -147,7 +147,7 @@ To encrypt Action Text fixtures, you should place them in `fixtures/action_text/ `active_record.encryption` will serialize values using the underlying type before encrypting them, but, unless using a custom `message_serializer`, *they must be serializable as strings*. Structured types like `serialized` are supported out of the box. -If you need to support a custom type, the recommended way is using a [serialized attribute](https://api.rubyonrails.org/classes/ActiveRecord/AttributeMethods/Serialization/ClassMethods.html). The declaration of the serialized attribute should go **before** the encryption declaration: +If you need to support a custom type, the recommended way is to use a [serialized attribute](https://api.rubyonrails.org/classes/ActiveRecord/AttributeMethods/Serialization/ClassMethods.html). The declaration of the serialized attribute should go **before** the encryption declaration: ```ruby # CORRECT @@ -188,7 +188,7 @@ end To ease migrations of unencrypted data, the library includes the option `config.active_record.encryption.support_unencrypted_data`. When set to `true`: * Trying to read encrypted attributes that are not encrypted will work normally, without raising any error. -* Queries with deterministically-encrypted attributes will include the "clear text" version of them to support finding both encrypted and unencrypted content. You need to set `config.active_record.encryption.extend_queries = true` to enable this. +* Queries with deterministically encrypted attributes will include the "clear text" version of them to support finding both encrypted and unencrypted content. You need to set `config.active_record.encryption.extend_queries = true` to enable this. **This option is meant to be used during transition periods** while clear data and encrypted data must coexist. Both are set to `false` by default, which is the recommended goal for any application: errors will be raised when working with unencrypted data. @@ -208,7 +208,7 @@ You can configure previous encryption schemes: #### Global Previous Encryption Schemes -You can add previous encryption schemes by adding them as list of properties using the `previous` config property in your `application.rb`: +You can add previous encryption schemes by adding them as a list of properties using the `previous` config property in your `application.rb`: ```ruby config.active_record.encryption.previous = [ { key_provider: MyOldKeyProvider.new } ] @@ -254,7 +254,7 @@ NOTE: If you want to ignore case, make sure to use `downcase:` or `ignore_case:` #### Unique Indexes -To support unique indexes on deterministically-encrypted columns, you need to ensure their ciphertext doesn't ever change. +To support unique indexes on deterministically encrypted columns, you need to ensure their ciphertext doesn't ever change. To encourage this, deterministic attributes will always use the oldest available encryption scheme by default when multiple encryption schemes are configured. Otherwise, it's your job to ensure encryption properties don't change for these attributes, or the unique indexes won't work. @@ -266,7 +266,7 @@ end ### Filtering Params Named as Encrypted Columns -By default, encrypted columns are configured to be [automatically filtered in Rails logs](action_controller_overview.html#parameters-filtering). You can disable this behavior by adding the following to your `application.rb`: +By default, encrypted columns are configured to be [automatically filtered in Rails logs](configuring.html#config-filter-parameters). You can disable this behavior by adding the following to your `application.rb`: ```ruby config.active_record.encryption.add_to_filter_parameters = false @@ -336,7 +336,7 @@ config.active_record.encryption.compressor = ZstdCompressor ## Key Management -Key providers implement key management strategies. You can configure key providers globally, or on a per attribute basis. +Key providers implement key management strategies. You can configure key providers globally or on a per-attribute basis. ### Built-in Key Providers @@ -464,7 +464,7 @@ article.decrypt # decrypt all the encryptable attributes article.ciphertext_for(:title) ``` -#### Check if Attribute is Encrypted or Not +#### Check if the Attribute is Encrypted or Not ```ruby article.encrypted_attribute?(:title) @@ -530,12 +530,12 @@ The digest algorithm used to derive keys. `OpenSSL::Digest::SHA256` by default. #### `config.active_record.encryption.support_sha1_for_non_deterministic_encryption` -Supports decrypting data encrypted non-deterministically with a digest class SHA1. Default is false, which +Supports decrypting data encrypted non-deterministically with a digest class SHA1. The default is false, which means it will only support the digest algorithm configured in `config.active_record.encryption.hash_digest_class`. #### `config.active_record.encryption.compressor` -The compressor used to compress encrypted payloads. It should respond to `deflate` and `inflate`. Default is `Zlib`. You can find more information about compressors in the [Compression](#compression) section. +The compressor used to compress encrypted payloads. It should respond to `deflate` and `inflate`. The default is `Zlib`. You can find more information about compressors in the [Compression](#compression) section. ### Encryption Contexts @@ -550,7 +550,7 @@ The main components of encryption contexts are: * `key_provider`: serves encryption and decryption keys. * `message_serializer`: serializes and deserializes encrypted payloads (`Message`). -NOTE: If you decide to build your own `message_serializer`, it's important to use safe mechanisms that can't deserialize arbitrary objects. A common supported scenario is encrypting existing unencrypted data. An attacker can leverage this to enter a tampered payload before encryption takes place and perform RCE attacks. This means custom serializers should avoid `Marshal`, `YAML.load` (use `YAML.safe_load` instead), or `JSON.load` (use `JSON.parse` instead). +NOTE: If you decide to build your own `message_serializer`, it's important to use safe mechanisms that can't deserialize arbitrary objects. A commonly supported scenario is encrypting existing unencrypted data. An attacker can leverage this to enter a tampered payload before encryption takes place and perform RCE attacks. This means custom serializers should avoid `Marshal`, `YAML.load` (use `YAML.safe_load` instead), or `JSON.load` (use `JSON.parse` instead). #### Global Encryption Context From 18b54eacf28301cfb1c4c713de2287a5f89f5240 Mon Sep 17 00:00:00 2001 From: Earlopain <14981592+Earlopain@users.noreply.github.com> Date: Sat, 10 May 2025 14:45:04 +0200 Subject: [PATCH 0155/1075] Only load from `cgi` what is required In Ruby 3.5 most of the `cgi` gem will be removed (bundled gem). Only the various escape/unescape methods will be retained by default, which is luckily the only ones that rails actually uses. Practically, requiring `cgi` will still work, it just emits a warning (and other things like `CGI::Cookie` obviously won't be available). On older versions, `require "cgi/util"` is needed because the unescape* methods don't work otherwise. Rails itself only uses escape/unescape functions. https://bugs.ruby-lang.org/issues/21258 --- Gemfile.lock | 4 ---- actionpack/actionpack.gemspec | 1 - actionpack/lib/action_dispatch/journey/router.rb | 3 ++- actionview/actionview.gemspec | 1 - actionview/lib/action_view/helpers/form_helper.rb | 3 ++- actionview/lib/action_view/helpers/form_options_helper.rb | 3 ++- actionview/lib/action_view/helpers/form_tag_helper.rb | 3 ++- activesupport/lib/active_support/core_ext/object/to_query.rb | 3 ++- railties/lib/rails/info.rb | 3 ++- railties/railties.gemspec | 1 - tools/preview_docs.rb | 3 ++- 11 files changed, 14 insertions(+), 14 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index c12d3e53ba67e..fc30e259f0dd6 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -34,7 +34,6 @@ PATH actionpack (8.1.0.alpha) actionview (= 8.1.0.alpha) activesupport (= 8.1.0.alpha) - cgi nokogiri (>= 1.8.5) rack (>= 2.2.4) rack-session (>= 1.0.1) @@ -52,7 +51,6 @@ PATH actionview (8.1.0.alpha) activesupport (= 8.1.0.alpha) builder (~> 3.1) - cgi erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) @@ -101,7 +99,6 @@ PATH railties (8.1.0.alpha) actionpack (= 8.1.0.alpha) activesupport (= 8.1.0.alpha) - cgi irb (~> 1.13) rackup (>= 1.0.0) rake (>= 12.2) @@ -180,7 +177,6 @@ GEM rack-test (>= 0.6.3) regexp_parser (>= 1.5, < 3.0) xpath (~> 3.2) - cgi (0.4.2) chef-utils (18.6.2) concurrent-ruby childprocess (5.1.0) diff --git a/actionpack/actionpack.gemspec b/actionpack/actionpack.gemspec index bff6cbb4f3a91..053897cc7dbcd 100644 --- a/actionpack/actionpack.gemspec +++ b/actionpack/actionpack.gemspec @@ -35,7 +35,6 @@ Gem::Specification.new do |s| s.add_dependency "activesupport", version - s.add_dependency "cgi" s.add_dependency "nokogiri", ">= 1.8.5" s.add_dependency "rack", ">= 2.2.4" s.add_dependency "rack-session", ">= 1.0.1" diff --git a/actionpack/lib/action_dispatch/journey/router.rb b/actionpack/lib/action_dispatch/journey/router.rb index 7f27131030980..fc8ffd0f3a1ae 100644 --- a/actionpack/lib/action_dispatch/journey/router.rb +++ b/actionpack/lib/action_dispatch/journey/router.rb @@ -2,7 +2,8 @@ # :markup: markdown -require "cgi" +require "cgi/escape" +require "cgi/util" if RUBY_VERSION < "3.5" require "action_dispatch/journey/router/utils" require "action_dispatch/journey/routes" require "action_dispatch/journey/formatter" diff --git a/actionview/actionview.gemspec b/actionview/actionview.gemspec index 25a54a4bafc9b..f090b09969ae1 100644 --- a/actionview/actionview.gemspec +++ b/actionview/actionview.gemspec @@ -35,7 +35,6 @@ Gem::Specification.new do |s| s.add_dependency "activesupport", version - s.add_dependency "cgi" s.add_dependency "builder", "~> 3.1" s.add_dependency "erubi", "~> 1.11" s.add_dependency "rails-html-sanitizer", "~> 1.6" diff --git a/actionview/lib/action_view/helpers/form_helper.rb b/actionview/lib/action_view/helpers/form_helper.rb index 4967766029a80..8c45db658df55 100644 --- a/actionview/lib/action_view/helpers/form_helper.rb +++ b/actionview/lib/action_view/helpers/form_helper.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true -require "cgi" +require "cgi/escape" +require "cgi/util" if RUBY_VERSION < "3.5" require "action_view/helpers/date_helper" require "action_view/helpers/url_helper" require "action_view/helpers/form_tag_helper" diff --git a/actionview/lib/action_view/helpers/form_options_helper.rb b/actionview/lib/action_view/helpers/form_options_helper.rb index e0256a4a3d4fd..44812e1399b54 100644 --- a/actionview/lib/action_view/helpers/form_options_helper.rb +++ b/actionview/lib/action_view/helpers/form_options_helper.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true -require "cgi" +require "cgi/escape" +require "cgi/util" if RUBY_VERSION < "3.5" require "erb" require "active_support/core_ext/string/output_safety" require "active_support/core_ext/array/extract_options" diff --git a/actionview/lib/action_view/helpers/form_tag_helper.rb b/actionview/lib/action_view/helpers/form_tag_helper.rb index 30cacd8a5aec3..b741756f0fe13 100644 --- a/actionview/lib/action_view/helpers/form_tag_helper.rb +++ b/actionview/lib/action_view/helpers/form_tag_helper.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true -require "cgi" +require "cgi/escape" +require "cgi/util" if RUBY_VERSION < "3.5" require "action_view/helpers/content_exfiltration_prevention_helper" require "action_view/helpers/url_helper" require "action_view/helpers/text_helper" diff --git a/activesupport/lib/active_support/core_ext/object/to_query.rb b/activesupport/lib/active_support/core_ext/object/to_query.rb index 42284d207bce2..5358e29a19fdb 100644 --- a/activesupport/lib/active_support/core_ext/object/to_query.rb +++ b/activesupport/lib/active_support/core_ext/object/to_query.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true -require "cgi" +require "cgi/escape" +require "cgi/util" if RUBY_VERSION < "3.5" class Object # Alias of to_s. diff --git a/railties/lib/rails/info.rb b/railties/lib/rails/info.rb index 64aa9f4a93294..491072fd23294 100644 --- a/railties/lib/rails/info.rb +++ b/railties/lib/rails/info.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true -require "cgi" +require "cgi/escape" +require "cgi/util" if RUBY_VERSION < "3.5" module Rails # This module helps build the runtime properties that are displayed in diff --git a/railties/railties.gemspec b/railties/railties.gemspec index de1e112fa10b7..918702e24cf3c 100644 --- a/railties/railties.gemspec +++ b/railties/railties.gemspec @@ -40,7 +40,6 @@ Gem::Specification.new do |s| s.add_dependency "activesupport", version s.add_dependency "actionpack", version - s.add_dependency "cgi" s.add_dependency "rackup", ">= 1.0.0" s.add_dependency "rake", ">= 12.2" s.add_dependency "thor", "~> 1.0", ">= 1.2.2" diff --git a/tools/preview_docs.rb b/tools/preview_docs.rb index 5eda30531bd92..ce2bef113c7d5 100644 --- a/tools/preview_docs.rb +++ b/tools/preview_docs.rb @@ -1,7 +1,8 @@ # frozen_string_literal: true require "erb" -require "cgi" +require "cgi/escape" +require "cgi/util" if RUBY_VERSION < "3.5" # How to test: # From 598e5d243958e1a747db114368fb39f6b446a22c Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Sat, 10 May 2025 14:59:05 -0400 Subject: [PATCH 0156/1075] dep: bump trix to v2.1.15 which addresses CVE-2025-46812 ref: https://github.com/advisories/GHSA-mcrw-746g-9q8h --- actiontext/app/assets/javascripts/trix.js | 31 ++++++++++++++++------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/actiontext/app/assets/javascripts/trix.js b/actiontext/app/assets/javascripts/trix.js index c1eae1566aea3..fafc535af5c4f 100644 --- a/actiontext/app/assets/javascripts/trix.js +++ b/actiontext/app/assets/javascripts/trix.js @@ -1,5 +1,7 @@ +// trix@2.1.15 downloaded from https://unpkg.com/trix@2.1.15/dist/trix.umd.js + /* -Trix 2.1.14 +Trix 2.1.15 Copyright © 2025 37signals, LLC */ (function (global, factory) { @@ -9,7 +11,7 @@ Copyright © 2025 37signals, LLC })(this, (function () { 'use strict'; var name = "trix"; - var version = "2.1.14"; + var version = "2.1.15"; var description = "A rich text editor for everyday writing"; var main = "dist/trix.umd.min.js"; var module = "dist/trix.esm.min.js"; @@ -3091,8 +3093,8 @@ $\ const DEFAULT_FORBIDDEN_PROTOCOLS = "javascript:".split(" "); const DEFAULT_FORBIDDEN_ELEMENTS = "script iframe form noscript".split(" "); class HTMLSanitizer extends BasicObject { - static setHTML(element, html) { - const sanitizedElement = new this(html).sanitize(); + static setHTML(element, html, options) { + const sanitizedElement = new this(html, options).sanitize(); const sanitizedHtml = sanitizedElement.getHTML ? sanitizedElement.getHTML() : sanitizedElement.outerHTML; element.innerHTML = sanitizedHtml; } @@ -3105,18 +3107,21 @@ $\ let { allowedAttributes, forbiddenProtocols, - forbiddenElements + forbiddenElements, + purifyOptions } = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; super(...arguments); this.allowedAttributes = allowedAttributes || DEFAULT_ALLOWED_ATTRIBUTES; this.forbiddenProtocols = forbiddenProtocols || DEFAULT_FORBIDDEN_PROTOCOLS; this.forbiddenElements = forbiddenElements || DEFAULT_FORBIDDEN_ELEMENTS; + this.purifyOptions = purifyOptions || {}; this.body = createBodyElementForHTML(html); } sanitize() { this.sanitizeElements(); this.normalizeListElementNesting(); - purify.setConfig(dompurify); + const purifyConfig = Object.assign({}, dompurify, this.purifyOptions); + purify.setConfig(purifyConfig); this.body = purify.sanitize(this.body); return this.body; } @@ -8369,11 +8374,13 @@ $\ } constructor(html) { let { - referenceElement + referenceElement, + purifyOptions } = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; super(...arguments); this.html = html; this.referenceElement = referenceElement; + this.purifyOptions = purifyOptions; this.blocks = []; this.blockElements = []; this.processedElements = []; @@ -8387,7 +8394,9 @@ $\ parse() { try { this.createHiddenContainer(); - HTMLSanitizer.setHTML(this.containerElement, this.html); + HTMLSanitizer.setHTML(this.containerElement, this.html, { + purifyOptions: this.purifyOptions + }); const walker = walkTree(this.containerElement, { usingFilter: nodeFilter }); @@ -9067,7 +9076,11 @@ $\ } } insertHTML(html) { - const document = HTMLParser.parse(html).getDocument(); + const document = HTMLParser.parse(html, { + purifyOptions: { + SAFE_FOR_XML: true + } + }).getDocument(); const selectedRange = this.getSelectedRange(); this.setDocument(this.document.mergeDocumentAtRange(document, selectedRange)); const startPosition = selectedRange[0]; From ddb671759bd9b8efec4562572b1d47bf879326a7 Mon Sep 17 00:00:00 2001 From: Paul Kim Date: Sat, 10 May 2025 21:04:11 -0400 Subject: [PATCH 0157/1075] Fix duplicate items in action controller renderer docs --- actionpack/lib/action_controller/renderer.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/actionpack/lib/action_controller/renderer.rb b/actionpack/lib/action_controller/renderer.rb index 068d023bc9585..dd0f01c345b56 100644 --- a/actionpack/lib/action_controller/renderer.rb +++ b/actionpack/lib/action_controller/renderer.rb @@ -96,7 +96,6 @@ def with_defaults(defaults) # * `:script_name` - The portion of the incoming request's URL path that # corresponds to the application. Converts to Rack's `SCRIPT_NAME`. # * `:input` - The input stream. Converts to Rack's `rack.input`. - # # * `defaults` - Default values for the Rack env. Entries are specified in the # same format as `env`. `env` will be merged on top of these values. # `defaults` will be retained when calling #new on a renderer instance. From c44bde69b64aec2fea784b10c984f404f1fae138 Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Sun, 11 May 2025 22:06:57 +0200 Subject: [PATCH 0158/1075] Add some missing :nodoc: --- activerecord/lib/active_record/relation/query_methods.rb | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/activerecord/lib/active_record/relation/query_methods.rb b/activerecord/lib/active_record/relation/query_methods.rb index abb796390a75a..feba4e73116b4 100644 --- a/activerecord/lib/active_record/relation/query_methods.rb +++ b/activerecord/lib/active_record/relation/query_methods.rb @@ -1657,13 +1657,12 @@ def build_where_clause(opts, rest = []) # :nodoc: end alias :build_having_clause :build_where_clause - def async! + def async! # :nodoc: @async = true self end - protected - def arel_columns(columns) + def arel_columns(columns) # :nodoc: columns.flat_map do |field| case field when Symbol, String From fb89f44a9c8334ecc7a37bfb596b74a371578c15 Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Mon, 12 May 2025 10:31:01 +0200 Subject: [PATCH 0159/1075] Update `set` gem for Ruby 3.5.0-dev compatibility Ref: https://github.com/ruby/set/pull/42 --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index fc30e259f0dd6..016fa0029d839 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -582,7 +582,7 @@ GEM websocket (~> 1.0) serverengine (2.0.7) sigdump (~> 0.2.2) - set (1.1.1) + set (1.1.2) sidekiq (8.0.2) connection_pool (>= 2.5.0) json (>= 2.9.0) From 268a479cf88666f503f15382e79acb80520e7e5d Mon Sep 17 00:00:00 2001 From: SeongHoon Ryu <4997174+ryush00@users.noreply.github.com> Date: Tue, 13 May 2025 00:27:50 +0900 Subject: [PATCH 0160/1075] Use inline queue adapter in bug report template With the default async adapter, some jobs like ActiveStorage::AnalyzeJob fail because they don't share the SQLite3 in-memory database. --- guides/bug_report_templates/active_storage.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/guides/bug_report_templates/active_storage.rb b/guides/bug_report_templates/active_storage.rb index 03b46a0b0b9e7..c0ebb4262b4f6 100644 --- a/guides/bug_report_templates/active_storage.rb +++ b/guides/bug_report_templates/active_storage.rb @@ -37,6 +37,8 @@ class TestApp < Rails::Application service: "Disk" } } + + config.active_job.queue_adapter = :inline end Rails.application.initialize! From dbbf13ef5a050de9d2fd48b823aba28dbc517ba5 Mon Sep 17 00:00:00 2001 From: Biruk Haileye Tabor Date: Mon, 12 May 2025 18:30:35 +0300 Subject: [PATCH 0161/1075] Use default-mysql-client in Dockerfile for trilogy Use default-mysql-client in Dockerfile to support trilogy rails dbconsole --- railties/lib/rails/generators/database.rb | 2 +- railties/test/generators/db_system_change_generator_test.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/railties/lib/rails/generators/database.rb b/railties/lib/rails/generators/database.rb index 287f4a90a4bce..b687b4e14c5c8 100644 --- a/railties/lib/rails/generators/database.rb +++ b/railties/lib/rails/generators/database.rb @@ -218,7 +218,7 @@ def gem end def base_package - nil + "default-mysql-client" end def build_package diff --git a/railties/test/generators/db_system_change_generator_test.rb b/railties/test/generators/db_system_change_generator_test.rb index 6288a36f76ac7..e1bbfe339d798 100644 --- a/railties/test/generators/db_system_change_generator_test.rb +++ b/railties/test/generators/db_system_change_generator_test.rb @@ -156,7 +156,7 @@ class ChangeGeneratorTest < Rails::Generators::TestCase assert_file("Dockerfile") do |content| assert_match "build-essential git", content - assert_match "curl libvips", content + assert_match "curl default-mysql-client libvips", content assert_no_match "default-libmysqlclient-dev", content end end From 55b54a33f7e6e2e51e23ead6ec3309576b5b8dd6 Mon Sep 17 00:00:00 2001 From: egg528 Date: Fri, 9 May 2025 00:02:14 +0900 Subject: [PATCH 0162/1075] Add support for Cache-Control request directives Co-Authored-By: Jean Boussier --- actionpack/CHANGELOG.md | 39 +++++++ actionpack/lib/action_dispatch/http/cache.rb | 108 +++++++++++++++++++ actionpack/test/dispatch/request_test.rb | 102 ++++++++++++++++++ 3 files changed, 249 insertions(+) diff --git a/actionpack/CHANGELOG.md b/actionpack/CHANGELOG.md index 1b5fb8e2f6358..de6b30e25f89a 100644 --- a/actionpack/CHANGELOG.md +++ b/actionpack/CHANGELOG.md @@ -1,3 +1,42 @@ +* Add comprehensive support for HTTP Cache-Control request directives according to RFC 9111. + + Provides a `request.cache_control_directives` object that gives access to request cache directives: + + ```ruby + # Boolean directives + request.cache_control_directives.only_if_cached? # => true/false + request.cache_control_directives.no_cache? # => true/false + request.cache_control_directives.no_store? # => true/false + request.cache_control_directives.no_transform? # => true/false + + # Value directives + request.cache_control_directives.max_age # => integer or nil + request.cache_control_directives.max_stale # => integer or nil (or true for valueless max-stale) + request.cache_control_directives.min_fresh # => integer or nil + request.cache_control_directives.stale_if_error # => integer or nil + + # Special helpers for max-stale + request.cache_control_directives.max_stale? # => true if max-stale present (with or without value) + request.cache_control_directives.max_stale_unlimited? # => true only for valueless max-stale + ``` + + Example usage: + + ```ruby + def show + if request.cache_control_directives.only_if_cached? + @article = Article.find_cached(params[:id]) + return head(:gateway_timeout) if @article.nil? + else + @article = Article.find(params[:id]) + end + + render :show + end + ``` + + *egg528* + * Add assert_in_body/assert_not_in_body as the simplest way to check if a piece of text is in the response body. *DHH* diff --git a/actionpack/lib/action_dispatch/http/cache.rb b/actionpack/lib/action_dispatch/http/cache.rb index 656807b929ffc..1486309d85edd 100644 --- a/actionpack/lib/action_dispatch/http/cache.rb +++ b/actionpack/lib/action_dispatch/http/cache.rb @@ -63,6 +63,114 @@ def fresh?(response) success end end + + def cache_control_directives + @cache_control_directives ||= CacheControlDirectives.new(get_header("HTTP_CACHE_CONTROL")) + end + + # Represents the HTTP Cache-Control header for requests, + # providing methods to access various cache control directives + # Reference: https://www.rfc-editor.org/rfc/rfc9111.html#name-request-directives + class CacheControlDirectives + def initialize(cache_control_header) + @only_if_cached = false + @no_cache = false + @no_store = false + @no_transform = false + @max_age = nil + @max_stale = nil + @min_fresh = nil + @stale_if_error = false + parse_directives(cache_control_header) + end + + # Returns true if the only-if-cached directive is present. + # This directive indicates that the client only wishes to obtain a + # stored response. If a valid stored response is not available, + # the server should respond with a 504 (Gateway Timeout) status. + def only_if_cached? + @only_if_cached + end + + # Returns true if the no-cache directive is present. + # This directive indicates that a cache must not use the response + # to satisfy subsequent requests without successful validation on the origin server. + def no_cache? + @no_cache + end + + # Returns true if the no-store directive is present. + # This directive indicates that a cache must not store any part of the + # request or response. + def no_store? + @no_store + end + + # Returns true if the no-transform directive is present. + # This directive indicates that a cache or proxy must not transform the payload. + def no_transform? + @no_transform + end + + # Returns the value of the max-age directive. + # This directive indicates that the client is willing to accept a response + # whose age is no greater than the specified number of seconds. + attr_reader :max_age + + # Returns the value of the max-stale directive. + # When max-stale is present with a value, returns that integer value. + # When max-stale is present without a value, returns true (unlimited staleness). + # When max-stale is not present, returns nil. + attr_reader :max_stale + + # Returns true if max-stale directive is present (with or without a value) + def max_stale? + !@max_stale.nil? + end + + # Returns true if max-stale directive is present without a value (unlimited staleness) + def max_stale_unlimited? + @max_stale == true + end + + # Returns the value of the min-fresh directive. + # This directive indicates that the client is willing to accept a response + # whose freshness lifetime is no less than its current age plus the specified time in seconds. + attr_reader :min_fresh + + # Returns the value of the stale-if-error directive. + # This directive indicates that the client is willing to accept a stale response + # if the check for a fresh one fails with an error for the specified number of seconds. + attr_reader :stale_if_error + + private + def parse_directives(header_value) + return unless header_value + + header_value.delete(" ").downcase.split(",").each do |directive| + name, value = directive.split("=", 2) + + case name + when "max-age" + @max_age = value.to_i + when "min-fresh" + @min_fresh = value.to_i + when "stale-if-error" + @stale_if_error = value.to_i + when "no-cache" + @no_cache = true + when "no-store" + @no_store = true + when "no-transform" + @no_transform = true + when "only-if-cached" + @only_if_cached = true + when "max-stale" + @max_stale = value ? value.to_i : true + end + end + end + end end module Response diff --git a/actionpack/test/dispatch/request_test.rb b/actionpack/test/dispatch/request_test.rb index 7120c8d05cd82..e0f818d4a596d 100644 --- a/actionpack/test/dispatch/request_test.rb +++ b/actionpack/test/dispatch/request_test.rb @@ -1446,3 +1446,105 @@ def setup assert_instance_of(ActionDispatch::Request::Session::Options, ActionDispatch::Request::Session::Options.find(@request)) end end + +class RequestCacheControlDirectives < BaseRequestTest + test "lazily initializes cache_control_directives" do + request = stub_request + assert_not_includes request.instance_variables, :@cache_control_directives + + request.cache_control_directives + assert_includes request.instance_variables, :@cache_control_directives + end + + test "only_if_cached? is true when only-if-cached is the sole directive" do + request = stub_request("HTTP_CACHE_CONTROL" => "only-if-cached") + assert_predicate request.cache_control_directives, :only_if_cached? + end + + test "only_if_cached? is true when only-if-cached appears among multiple directives" do + request = stub_request("HTTP_CACHE_CONTROL" => "max-age=60, only-if-cached") + assert_predicate request.cache_control_directives, :only_if_cached? + end + + test "only_if_cached? is false when Cache-Control header is missing" do + request = stub_request + assert_not_predicate request.cache_control_directives, :only_if_cached? + end + + test "no_cache? properly detects the no-cache directive" do + request = stub_request("HTTP_CACHE_CONTROL" => "no-cache") + assert_predicate request.cache_control_directives, :no_cache? + end + + test "no_store? properly detects the no-store directive" do + request = stub_request("HTTP_CACHE_CONTROL" => "no-store") + assert_predicate request.cache_control_directives, :no_store? + end + + test "no_transform? properly detects the no-transform directive" do + request = stub_request("HTTP_CACHE_CONTROL" => "no-transform") + assert_predicate request.cache_control_directives, :no_transform? + end + + test "max_age properly returns the max-age directive value" do + request = stub_request("HTTP_CACHE_CONTROL" => "max-age=60") + assert_equal 60, request.cache_control_directives.max_age + end + + test "max_stale properly returns the max-stale directive value" do + request = stub_request("HTTP_CACHE_CONTROL" => "max-stale=300") + assert_equal 300, request.cache_control_directives.max_stale + end + + test "max_stale returns true when max-stale is present without a value" do + request = stub_request("HTTP_CACHE_CONTROL" => "max-stale") + assert_equal true, request.cache_control_directives.max_stale + end + + test "max_stale? returns true when max-stale is present with or without a value" do + request = stub_request("HTTP_CACHE_CONTROL" => "max-stale=300") + assert_predicate request.cache_control_directives, :max_stale? + + request = stub_request("HTTP_CACHE_CONTROL" => "max-stale") + assert_predicate request.cache_control_directives, :max_stale? + end + + test "max_stale? returns false when max-stale is not present" do + request = stub_request + assert_not_predicate request.cache_control_directives, :max_stale? + end + + test "max_stale_unlimited? returns true only when max-stale is present without a value" do + request = stub_request("HTTP_CACHE_CONTROL" => "max-stale") + assert_predicate request.cache_control_directives, :max_stale_unlimited? + + request = stub_request("HTTP_CACHE_CONTROL" => "max-stale=300") + assert_not_predicate request.cache_control_directives, :max_stale_unlimited? + + request = stub_request + assert_not_predicate request.cache_control_directives, :max_stale_unlimited? + end + + test "min_fresh properly returns the min-fresh directive value" do + request = stub_request("HTTP_CACHE_CONTROL" => "min-fresh=120") + assert_equal 120, request.cache_control_directives.min_fresh + end + + test "stale_if_error properly returns the stale-if-error directive value" do + request = stub_request("HTTP_CACHE_CONTROL" => "stale-if-error=600") + assert_equal 600, request.cache_control_directives.stale_if_error + end + + test "handles Cache-Control header with whitespace and case insensitivity" do + request = stub_request("HTTP_CACHE_CONTROL" => " Max-Age=60 , No-Cache ") + assert_equal 60, request.cache_control_directives.max_age + assert_predicate request.cache_control_directives, :no_cache? + end + + test "ignores unrecognized directives" do + request = stub_request("HTTP_CACHE_CONTROL" => "max-age=60, unknown-directive, foo=bar") + assert_equal 60, request.cache_control_directives.max_age + assert_not_predicate request.cache_control_directives, :no_cache? + assert_not_predicate request.cache_control_directives, :no_store? + end +end From 09e7eb2a71dc32b2d73e646b90fcb0aae80c354d Mon Sep 17 00:00:00 2001 From: Darius Schneider Date: Tue, 13 May 2025 09:42:03 +0200 Subject: [PATCH 0163/1075] Add note in stating that are not reported in the context of HTTP requests. Co-Authored-By: Jean Boussier --- guides/source/error_reporting.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/guides/source/error_reporting.md b/guides/source/error_reporting.md index 88431e2047f32..9126c53ec6b63 100644 --- a/guides/source/error_reporting.md +++ b/guides/source/error_reporting.md @@ -46,6 +46,9 @@ requests, so any unhandled errors raised in your app will automatically be reported to your error-reporting service via their subscribers. +NOTE: For HTTP requests, errors present in `ActionDispatch::ExceptionWrapper.rescue_responses` +are not reported as they do not result in server errors (500) and generally aren't bugs that need to be addressed. + This means that third-party error-reporting libraries no longer need to insert a [Rack](rails_on_rack.html) middleware or do any monkey-patching to capture unhandled errors. Libraries that use [Active From d0d2d31f62cae9bdc9360b9f179b4311377e9957 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Sat, 10 May 2025 17:43:30 -0400 Subject: [PATCH 0164/1075] Drop vendored Trix files in favor of the action_text-trix gem which will allow applications to bump Trix versions independently of Rails releases. Closes #54148 --- Gemfile.lock | 3 + actiontext/CHANGELOG.md | 6 + actiontext/Rakefile | 23 - actiontext/actiontext.gemspec | 1 + .../app/assets/javascripts/.gitattributes | 1 - actiontext/app/assets/javascripts/trix.js | 13786 ---------------- actiontext/app/assets/stylesheets/trix.css | 470 - actiontext/lib/action_text/engine.rb | 3 +- railties/test/railties/engine_test.rb | 1 + 9 files changed, 13 insertions(+), 14281 deletions(-) delete mode 100644 actiontext/app/assets/javascripts/trix.js delete mode 100644 actiontext/app/assets/stylesheets/trix.css diff --git a/Gemfile.lock b/Gemfile.lock index 016fa0029d839..80db1f5673686 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -42,6 +42,7 @@ PATH rails-html-sanitizer (~> 1.6) useragent (~> 0.16) actiontext (8.1.0.alpha) + action_text-trix (~> 2.1.15) actionpack (= 8.1.0.alpha) activerecord (= 8.1.0.alpha) activestorage (= 8.1.0.alpha) @@ -115,6 +116,8 @@ PATH GEM remote: https://rubygems.org/ specs: + action_text-trix (2.1.15) + railties addressable (2.8.7) public_suffix (>= 2.0.2, < 7.0) amq-protocol (2.3.2) diff --git a/actiontext/CHANGELOG.md b/actiontext/CHANGELOG.md index 632794ce57592..9f1ff1b275c85 100644 --- a/actiontext/CHANGELOG.md +++ b/actiontext/CHANGELOG.md @@ -1,3 +1,9 @@ +* The Trix dependency is now satisfied by a gem, `action_text-trix`, rather than vendored + files. This allows applications to bump Trix versions independently of Rails + releases. Effectively this also upgrades Trix to `>= 2.1.15`. + + *Mike Dalessio* + * Change `ActionText::RichText#embeds` assignment from `before_save` to `before_validation` *Sean Doyle* diff --git a/actiontext/Rakefile b/actiontext/Rakefile index 5df8ce5437792..99c094323492e 100644 --- a/actiontext/Rakefile +++ b/actiontext/Rakefile @@ -28,27 +28,4 @@ namespace :test do end end -task :vendor_trix do - require "importmap-rails" - require "importmap/packager" - - packager = Importmap::Packager.new(vendor_path: "app/assets/javascripts") - imports = packager.import("trix", from: "unpkg") - imports.each do |package, url| - url.gsub!("esm.min.js", "umd.js") - puts %(Vendoring "#{package}" to #{packager.vendor_path}/#{package}.js via download from #{url}) - packager.download(package, url) - - css_url = url.gsub("umd.js", "css") - puts %(Vendoring "#{package}" to #{packager.vendor_path}/#{package}.css via download from #{css_url}) - - response = Net::HTTP.get_response(URI(css_url)) - if response.code == "200" - File.open(Pathname.new("app/assets/stylesheets/trix.css"), "w+") do |file| - file.write response.body - end - end - end -end - task default: :test diff --git a/actiontext/actiontext.gemspec b/actiontext/actiontext.gemspec index c80c43ab092ca..e351bba47fd17 100644 --- a/actiontext/actiontext.gemspec +++ b/actiontext/actiontext.gemspec @@ -39,4 +39,5 @@ Gem::Specification.new do |s| s.add_dependency "nokogiri", ">= 1.8.5" s.add_dependency "globalid", ">= 0.6.0" + s.add_dependency "action_text-trix", "~> 2.1.15" end diff --git a/actiontext/app/assets/javascripts/.gitattributes b/actiontext/app/assets/javascripts/.gitattributes index 65a3ad3f8bcdc..1f28b2bca67c9 100644 --- a/actiontext/app/assets/javascripts/.gitattributes +++ b/actiontext/app/assets/javascripts/.gitattributes @@ -1,3 +1,2 @@ actiontext.js linguist-generated actiontext.esm.js linguist-generated -trix.js linguist-vendored diff --git a/actiontext/app/assets/javascripts/trix.js b/actiontext/app/assets/javascripts/trix.js deleted file mode 100644 index fafc535af5c4f..0000000000000 --- a/actiontext/app/assets/javascripts/trix.js +++ /dev/null @@ -1,13786 +0,0 @@ -// trix@2.1.15 downloaded from https://unpkg.com/trix@2.1.15/dist/trix.umd.js - -/* -Trix 2.1.15 -Copyright © 2025 37signals, LLC - */ -(function (global, factory) { - typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : - typeof define === 'function' && define.amd ? define(factory) : - (global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.Trix = factory()); -})(this, (function () { 'use strict'; - - var name = "trix"; - var version = "2.1.15"; - var description = "A rich text editor for everyday writing"; - var main = "dist/trix.umd.min.js"; - var module = "dist/trix.esm.min.js"; - var style = "dist/trix.css"; - var files = [ - "dist/*.css", - "dist/*.js", - "dist/*.map", - "src/{inspector,trix}/*.js" - ]; - var repository = { - type: "git", - url: "git+https://github.com/basecamp/trix.git" - }; - var keywords = [ - "rich text", - "wysiwyg", - "editor" - ]; - var author = "37signals, LLC"; - var license = "MIT"; - var bugs = { - url: "https://github.com/basecamp/trix/issues" - }; - var homepage = "https://trix-editor.org/"; - var devDependencies = { - "@babel/core": "^7.16.0", - "@babel/preset-env": "^7.16.4", - "@rollup/plugin-babel": "^5.3.0", - "@rollup/plugin-commonjs": "^22.0.2", - "@rollup/plugin-json": "^4.1.0", - "@rollup/plugin-node-resolve": "^13.3.0", - "@web/dev-server": "^0.1.34", - "babel-eslint": "^10.1.0", - chokidar: "^4.0.2", - concurrently: "^7.4.0", - eslint: "^7.32.0", - esm: "^3.2.25", - karma: "6.4.1", - "karma-chrome-launcher": "3.2.0", - "karma-qunit": "^4.1.2", - "karma-sauce-launcher": "^4.3.6", - qunit: "2.19.1", - rangy: "^1.3.0", - rollup: "^2.56.3", - "rollup-plugin-includepaths": "^0.2.4", - "rollup-plugin-terser": "^7.0.2", - sass: "^1.83.0", - svgo: "^2.8.0", - webdriverio: "^7.19.5" - }; - var resolutions = { - webdriverio: "^7.19.5" - }; - var scripts = { - "build-css": "bin/sass-build assets/trix.scss dist/trix.css", - "build-js": "rollup -c", - "build-assets": "cp -f assets/*.html dist/", - build: "yarn run build-js && yarn run build-css && yarn run build-assets", - watch: "rollup -c -w", - lint: "eslint .", - pretest: "yarn run lint && yarn run build", - test: "karma start", - prerelease: "yarn version && yarn test", - release: "npm adduser && npm publish", - postrelease: "git push && git push --tags", - dev: "web-dev-server --app-index index.html --root-dir dist --node-resolve --open", - start: "yarn build-assets && concurrently --kill-others --names js,css,dev-server 'yarn watch' 'yarn build-css --watch' 'yarn dev'" - }; - var dependencies = { - dompurify: "^3.2.5" - }; - var _package = { - name: name, - version: version, - description: description, - main: main, - module: module, - style: style, - files: files, - repository: repository, - keywords: keywords, - author: author, - license: license, - bugs: bugs, - homepage: homepage, - devDependencies: devDependencies, - resolutions: resolutions, - scripts: scripts, - dependencies: dependencies - }; - - const attachmentSelector = "[data-trix-attachment]"; - const attachments = { - preview: { - presentation: "gallery", - caption: { - name: true, - size: true - } - }, - file: { - caption: { - size: true - } - } - }; - - const attributes = { - default: { - tagName: "div", - parse: false - }, - quote: { - tagName: "blockquote", - nestable: true - }, - heading1: { - tagName: "h1", - terminal: true, - breakOnReturn: true, - group: false - }, - code: { - tagName: "pre", - terminal: true, - htmlAttributes: ["language"], - text: { - plaintext: true - } - }, - bulletList: { - tagName: "ul", - parse: false - }, - bullet: { - tagName: "li", - listAttribute: "bulletList", - group: false, - nestable: true, - test(element) { - return tagName$1(element.parentNode) === attributes[this.listAttribute].tagName; - } - }, - numberList: { - tagName: "ol", - parse: false - }, - number: { - tagName: "li", - listAttribute: "numberList", - group: false, - nestable: true, - test(element) { - return tagName$1(element.parentNode) === attributes[this.listAttribute].tagName; - } - }, - attachmentGallery: { - tagName: "div", - exclusive: true, - terminal: true, - parse: false, - group: false - } - }; - const tagName$1 = element => { - var _element$tagName; - return element === null || element === void 0 || (_element$tagName = element.tagName) === null || _element$tagName === void 0 ? void 0 : _element$tagName.toLowerCase(); - }; - - const androidVersionMatch = navigator.userAgent.match(/android\s([0-9]+.*Chrome)/i); - const androidVersion = androidVersionMatch && parseInt(androidVersionMatch[1]); - var browser$1 = { - // Android emits composition events when moving the cursor through existing text - // Introduced in Chrome 65: https://bugs.chromium.org/p/chromium/issues/detail?id=764439#c9 - composesExistingText: /Android.*Chrome/.test(navigator.userAgent), - // Android 13, especially on Samsung keyboards, emits extra compositionend and beforeinput events - // that can make the input handler lose the current selection or enter an infinite input -> render -> input - // loop. - recentAndroid: androidVersion && androidVersion > 12, - samsungAndroid: androidVersion && navigator.userAgent.match(/Android.*SM-/), - // IE 11 activates resizing handles on editable elements that have "layout" - forcesObjectResizing: /Trident.*rv:11/.test(navigator.userAgent), - // https://www.w3.org/TR/input-events-1/ + https://www.w3.org/TR/input-events-2/ - supportsInputEvents: typeof InputEvent !== "undefined" && ["data", "getTargetRanges", "inputType"].every(prop => prop in InputEvent.prototype) - }; - - var css$3 = { - attachment: "attachment", - attachmentCaption: "attachment__caption", - attachmentCaptionEditor: "attachment__caption-editor", - attachmentMetadata: "attachment__metadata", - attachmentMetadataContainer: "attachment__metadata-container", - attachmentName: "attachment__name", - attachmentProgress: "attachment__progress", - attachmentSize: "attachment__size", - attachmentToolbar: "attachment__toolbar", - attachmentGallery: "attachment-gallery" - }; - - var dompurify = { - ADD_ATTR: ["language"], - SAFE_FOR_XML: false, - RETURN_DOM: true - }; - - var lang$1 = { - attachFiles: "Attach Files", - bold: "Bold", - bullets: "Bullets", - byte: "Byte", - bytes: "Bytes", - captionPlaceholder: "Add a caption…", - code: "Code", - heading1: "Heading", - indent: "Increase Level", - italic: "Italic", - link: "Link", - numbers: "Numbers", - outdent: "Decrease Level", - quote: "Quote", - redo: "Redo", - remove: "Remove", - strike: "Strikethrough", - undo: "Undo", - unlink: "Unlink", - url: "URL", - urlPlaceholder: "Enter a URL…", - GB: "GB", - KB: "KB", - MB: "MB", - PB: "PB", - TB: "TB" - }; - - /* eslint-disable - no-case-declarations, - */ - const sizes = [lang$1.bytes, lang$1.KB, lang$1.MB, lang$1.GB, lang$1.TB, lang$1.PB]; - var file_size_formatting = { - prefix: "IEC", - precision: 2, - formatter(number) { - switch (number) { - case 0: - return "0 ".concat(lang$1.bytes); - case 1: - return "1 ".concat(lang$1.byte); - default: - let base; - if (this.prefix === "SI") { - base = 1000; - } else if (this.prefix === "IEC") { - base = 1024; - } - const exp = Math.floor(Math.log(number) / Math.log(base)); - const humanSize = number / Math.pow(base, exp); - const string = humanSize.toFixed(this.precision); - const withoutInsignificantZeros = string.replace(/0*$/, "").replace(/\.$/, ""); - return "".concat(withoutInsignificantZeros, " ").concat(sizes[exp]); - } - } - }; - - const ZERO_WIDTH_SPACE = "\uFEFF"; - const NON_BREAKING_SPACE = "\u00A0"; - const OBJECT_REPLACEMENT_CHARACTER = "\uFFFC"; - - const extend = function (properties) { - for (const key in properties) { - const value = properties[key]; - this[key] = value; - } - return this; - }; - - const html$2 = document.documentElement; - const match = html$2.matches; - const handleEvent = function (eventName) { - let { - onElement, - matchingSelector, - withCallback, - inPhase, - preventDefault, - times - } = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; - const element = onElement ? onElement : html$2; - const selector = matchingSelector; - const useCapture = inPhase === "capturing"; - const handler = function (event) { - if (times != null && --times === 0) { - handler.destroy(); - } - const target = findClosestElementFromNode(event.target, { - matchingSelector: selector - }); - if (target != null) { - withCallback === null || withCallback === void 0 || withCallback.call(target, event, target); - if (preventDefault) { - event.preventDefault(); - } - } - }; - handler.destroy = () => element.removeEventListener(eventName, handler, useCapture); - element.addEventListener(eventName, handler, useCapture); - return handler; - }; - const handleEventOnce = function (eventName) { - let options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; - options.times = 1; - return handleEvent(eventName, options); - }; - const triggerEvent = function (eventName) { - let { - onElement, - bubbles, - cancelable, - attributes - } = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; - const element = onElement != null ? onElement : html$2; - bubbles = bubbles !== false; - cancelable = cancelable !== false; - const event = document.createEvent("Events"); - event.initEvent(eventName, bubbles, cancelable); - if (attributes != null) { - extend.call(event, attributes); - } - return element.dispatchEvent(event); - }; - const elementMatchesSelector = function (element, selector) { - if ((element === null || element === void 0 ? void 0 : element.nodeType) === 1) { - return match.call(element, selector); - } - }; - const findClosestElementFromNode = function (node) { - let { - matchingSelector, - untilNode - } = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; - while (node && node.nodeType !== Node.ELEMENT_NODE) { - node = node.parentNode; - } - if (node == null) { - return; - } - if (matchingSelector != null) { - if (node.closest && untilNode == null) { - return node.closest(matchingSelector); - } else { - while (node && node !== untilNode) { - if (elementMatchesSelector(node, matchingSelector)) { - return node; - } - node = node.parentNode; - } - } - } else { - return node; - } - }; - const findInnerElement = function (element) { - while ((_element = element) !== null && _element !== void 0 && _element.firstElementChild) { - var _element; - element = element.firstElementChild; - } - return element; - }; - const innerElementIsActive = element => document.activeElement !== element && elementContainsNode(element, document.activeElement); - const elementContainsNode = function (element, node) { - if (!element || !node) { - return; - } - while (node) { - if (node === element) { - return true; - } - node = node.parentNode; - } - }; - const findNodeFromContainerAndOffset = function (container, offset) { - if (!container) { - return; - } - if (container.nodeType === Node.TEXT_NODE) { - return container; - } else if (offset === 0) { - return container.firstChild != null ? container.firstChild : container; - } else { - return container.childNodes.item(offset - 1); - } - }; - const findElementFromContainerAndOffset = function (container, offset) { - const node = findNodeFromContainerAndOffset(container, offset); - return findClosestElementFromNode(node); - }; - const findChildIndexOfNode = function (node) { - var _node; - if (!((_node = node) !== null && _node !== void 0 && _node.parentNode)) { - return; - } - let childIndex = 0; - node = node.previousSibling; - while (node) { - childIndex++; - node = node.previousSibling; - } - return childIndex; - }; - const removeNode = node => { - var _node$parentNode; - return node === null || node === void 0 || (_node$parentNode = node.parentNode) === null || _node$parentNode === void 0 ? void 0 : _node$parentNode.removeChild(node); - }; - const walkTree = function (tree) { - let { - onlyNodesOfType, - usingFilter, - expandEntityReferences - } = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; - const whatToShow = (() => { - switch (onlyNodesOfType) { - case "element": - return NodeFilter.SHOW_ELEMENT; - case "text": - return NodeFilter.SHOW_TEXT; - case "comment": - return NodeFilter.SHOW_COMMENT; - default: - return NodeFilter.SHOW_ALL; - } - })(); - return document.createTreeWalker(tree, whatToShow, usingFilter != null ? usingFilter : null, expandEntityReferences === true); - }; - const tagName = element => { - var _element$tagName; - return element === null || element === void 0 || (_element$tagName = element.tagName) === null || _element$tagName === void 0 ? void 0 : _element$tagName.toLowerCase(); - }; - const makeElement = function (tag) { - let options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; - let key, value; - if (typeof tag === "object") { - options = tag; - tag = options.tagName; - } else { - options = { - attributes: options - }; - } - const element = document.createElement(tag); - if (options.editable != null) { - if (options.attributes == null) { - options.attributes = {}; - } - options.attributes.contenteditable = options.editable; - } - if (options.attributes) { - for (key in options.attributes) { - value = options.attributes[key]; - element.setAttribute(key, value); - } - } - if (options.style) { - for (key in options.style) { - value = options.style[key]; - element.style[key] = value; - } - } - if (options.data) { - for (key in options.data) { - value = options.data[key]; - element.dataset[key] = value; - } - } - if (options.className) { - options.className.split(" ").forEach(className => { - element.classList.add(className); - }); - } - if (options.textContent) { - element.textContent = options.textContent; - } - if (options.childNodes) { - [].concat(options.childNodes).forEach(childNode => { - element.appendChild(childNode); - }); - } - return element; - }; - let blockTagNames = undefined; - const getBlockTagNames = function () { - if (blockTagNames != null) { - return blockTagNames; - } - blockTagNames = []; - for (const key in attributes) { - const attributes$1 = attributes[key]; - if (attributes$1.tagName) { - blockTagNames.push(attributes$1.tagName); - } - } - return blockTagNames; - }; - const nodeIsBlockContainer = node => nodeIsBlockStartComment(node === null || node === void 0 ? void 0 : node.firstChild); - const nodeProbablyIsBlockContainer = function (node) { - return getBlockTagNames().includes(tagName(node)) && !getBlockTagNames().includes(tagName(node.firstChild)); - }; - const nodeIsBlockStart = function (node) { - let { - strict - } = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : { - strict: true - }; - if (strict) { - return nodeIsBlockStartComment(node); - } else { - return nodeIsBlockStartComment(node) || !nodeIsBlockStartComment(node.firstChild) && nodeProbablyIsBlockContainer(node); - } - }; - const nodeIsBlockStartComment = node => nodeIsCommentNode(node) && (node === null || node === void 0 ? void 0 : node.data) === "block"; - const nodeIsCommentNode = node => (node === null || node === void 0 ? void 0 : node.nodeType) === Node.COMMENT_NODE; - const nodeIsCursorTarget = function (node) { - let { - name - } = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; - if (!node) { - return; - } - if (nodeIsTextNode(node)) { - if (node.data === ZERO_WIDTH_SPACE) { - if (name) { - return node.parentNode.dataset.trixCursorTarget === name; - } else { - return true; - } - } - } else { - return nodeIsCursorTarget(node.firstChild); - } - }; - const nodeIsAttachmentElement = node => elementMatchesSelector(node, attachmentSelector); - const nodeIsEmptyTextNode = node => nodeIsTextNode(node) && (node === null || node === void 0 ? void 0 : node.data) === ""; - const nodeIsTextNode = node => (node === null || node === void 0 ? void 0 : node.nodeType) === Node.TEXT_NODE; - - const input = { - level2Enabled: true, - getLevel() { - if (this.level2Enabled && browser$1.supportsInputEvents) { - return 2; - } else { - return 0; - } - }, - pickFiles(callback) { - const input = makeElement("input", { - type: "file", - multiple: true, - hidden: true, - id: this.fileInputId - }); - input.addEventListener("change", () => { - callback(input.files); - removeNode(input); - }); - removeNode(document.getElementById(this.fileInputId)); - document.body.appendChild(input); - input.click(); - } - }; - - var key_names = { - 8: "backspace", - 9: "tab", - 13: "return", - 27: "escape", - 37: "left", - 39: "right", - 46: "delete", - 68: "d", - 72: "h", - 79: "o" - }; - - var parser = { - removeBlankTableCells: false, - tableCellSeparator: " | ", - tableRowSeparator: "\n" - }; - - var text_attributes = { - bold: { - tagName: "strong", - inheritable: true, - parser(element) { - const style = window.getComputedStyle(element); - return style.fontWeight === "bold" || style.fontWeight >= 600; - } - }, - italic: { - tagName: "em", - inheritable: true, - parser(element) { - const style = window.getComputedStyle(element); - return style.fontStyle === "italic"; - } - }, - href: { - groupTagName: "a", - parser(element) { - const matchingSelector = "a:not(".concat(attachmentSelector, ")"); - const link = element.closest(matchingSelector); - if (link) { - return link.getAttribute("href"); - } - } - }, - strike: { - tagName: "del", - inheritable: true - }, - frozen: { - style: { - backgroundColor: "highlight" - } - } - }; - - var toolbar = { - getDefaultHTML() { - return "
\n \n \n \n \n \n \n\n \n \n \n \n \n \n \n \n \n\n \n \n \n\n \n\n \n \n \n \n
\n\n
\n
\n \n
\n
"); - } - }; - - const undo = { - interval: 5000 - }; - - var config = /*#__PURE__*/Object.freeze({ - __proto__: null, - attachments: attachments, - blockAttributes: attributes, - browser: browser$1, - css: css$3, - dompurify: dompurify, - fileSize: file_size_formatting, - input: input, - keyNames: key_names, - lang: lang$1, - parser: parser, - textAttributes: text_attributes, - toolbar: toolbar, - undo: undo - }); - - class BasicObject { - static proxyMethod(expression) { - const { - name, - toMethod, - toProperty, - optional - } = parseProxyMethodExpression(expression); - this.prototype[name] = function () { - let subject; - let object; - if (toMethod) { - if (optional) { - var _this$toMethod; - object = (_this$toMethod = this[toMethod]) === null || _this$toMethod === void 0 ? void 0 : _this$toMethod.call(this); - } else { - object = this[toMethod](); - } - } else if (toProperty) { - object = this[toProperty]; - } - if (optional) { - var _object; - subject = (_object = object) === null || _object === void 0 ? void 0 : _object[name]; - if (subject) { - return apply$1.call(subject, object, arguments); - } - } else { - subject = object[name]; - return apply$1.call(subject, object, arguments); - } - }; - } - } - const parseProxyMethodExpression = function (expression) { - const match = expression.match(proxyMethodExpressionPattern); - if (!match) { - throw new Error("can't parse @proxyMethod expression: ".concat(expression)); - } - const args = { - name: match[4] - }; - if (match[2] != null) { - args.toMethod = match[1]; - } else { - args.toProperty = match[1]; - } - if (match[3] != null) { - args.optional = true; - } - return args; - }; - const { - apply: apply$1 - } = Function.prototype; - const proxyMethodExpressionPattern = new RegExp("\ -^\ -(.+?)\ -(\\(\\))?\ -(\\?)?\ -\\.\ -(.+?)\ -$\ -"); - - var _Array$from, _$codePointAt$1, _$1, _String$fromCodePoint; - class UTF16String extends BasicObject { - static box() { - let value = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : ""; - if (value instanceof this) { - return value; - } else { - return this.fromUCS2String(value === null || value === void 0 ? void 0 : value.toString()); - } - } - static fromUCS2String(ucs2String) { - return new this(ucs2String, ucs2decode(ucs2String)); - } - static fromCodepoints(codepoints) { - return new this(ucs2encode(codepoints), codepoints); - } - constructor(ucs2String, codepoints) { - super(...arguments); - this.ucs2String = ucs2String; - this.codepoints = codepoints; - this.length = this.codepoints.length; - this.ucs2Length = this.ucs2String.length; - } - offsetToUCS2Offset(offset) { - return ucs2encode(this.codepoints.slice(0, Math.max(0, offset))).length; - } - offsetFromUCS2Offset(ucs2Offset) { - return ucs2decode(this.ucs2String.slice(0, Math.max(0, ucs2Offset))).length; - } - slice() { - return this.constructor.fromCodepoints(this.codepoints.slice(...arguments)); - } - charAt(offset) { - return this.slice(offset, offset + 1); - } - isEqualTo(value) { - return this.constructor.box(value).ucs2String === this.ucs2String; - } - toJSON() { - return this.ucs2String; - } - getCacheKey() { - return this.ucs2String; - } - toString() { - return this.ucs2String; - } - } - const hasArrayFrom = ((_Array$from = Array.from) === null || _Array$from === void 0 ? void 0 : _Array$from.call(Array, "\ud83d\udc7c").length) === 1; - const hasStringCodePointAt$1 = ((_$codePointAt$1 = (_$1 = " ").codePointAt) === null || _$codePointAt$1 === void 0 ? void 0 : _$codePointAt$1.call(_$1, 0)) != null; - const hasStringFromCodePoint = ((_String$fromCodePoint = String.fromCodePoint) === null || _String$fromCodePoint === void 0 ? void 0 : _String$fromCodePoint.call(String, 32, 128124)) === " \ud83d\udc7c"; - - // UCS-2 conversion helpers ported from Mathias Bynens' Punycode.js: - // https://github.com/bestiejs/punycode.js#punycodeucs2 - - let ucs2decode, ucs2encode; - - // Creates an array containing the numeric code points of each Unicode - // character in the string. While JavaScript uses UCS-2 internally, - // this function will convert a pair of surrogate halves (each of which - // UCS-2 exposes as separate characters) into a single code point, - // matching UTF-16. - if (hasArrayFrom && hasStringCodePointAt$1) { - ucs2decode = string => Array.from(string).map(char => char.codePointAt(0)); - } else { - ucs2decode = function (string) { - const output = []; - let counter = 0; - const { - length - } = string; - while (counter < length) { - let value = string.charCodeAt(counter++); - if (0xd800 <= value && value <= 0xdbff && counter < length) { - // high surrogate, and there is a next character - const extra = string.charCodeAt(counter++); - if ((extra & 0xfc00) === 0xdc00) { - // low surrogate - value = ((value & 0x3ff) << 10) + (extra & 0x3ff) + 0x10000; - } else { - // unmatched surrogate; only append this code unit, in case the - // next code unit is the high surrogate of a surrogate pair - counter--; - } - } - output.push(value); - } - return output; - }; - } - - // Creates a string based on an array of numeric code points. - if (hasStringFromCodePoint) { - ucs2encode = array => String.fromCodePoint(...Array.from(array || [])); - } else { - ucs2encode = function (array) { - const characters = (() => { - const result = []; - Array.from(array).forEach(value => { - let output = ""; - if (value > 0xffff) { - value -= 0x10000; - output += String.fromCharCode(value >>> 10 & 0x3ff | 0xd800); - value = 0xdc00 | value & 0x3ff; - } - result.push(output + String.fromCharCode(value)); - }); - return result; - })(); - return characters.join(""); - }; - } - - let id$2 = 0; - class TrixObject extends BasicObject { - static fromJSONString(jsonString) { - return this.fromJSON(JSON.parse(jsonString)); - } - constructor() { - super(...arguments); - this.id = ++id$2; - } - hasSameConstructorAs(object) { - return this.constructor === (object === null || object === void 0 ? void 0 : object.constructor); - } - isEqualTo(object) { - return this === object; - } - inspect() { - const parts = []; - const contents = this.contentsForInspection() || {}; - for (const key in contents) { - const value = contents[key]; - parts.push("".concat(key, "=").concat(value)); - } - return "#<".concat(this.constructor.name, ":").concat(this.id).concat(parts.length ? " ".concat(parts.join(", ")) : "", ">"); - } - contentsForInspection() {} - toJSONString() { - return JSON.stringify(this); - } - toUTF16String() { - return UTF16String.box(this); - } - getCacheKey() { - return this.id.toString(); - } - } - - /* eslint-disable - id-length, - */ - const arraysAreEqual = function () { - let a = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : []; - let b = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : []; - if (a.length !== b.length) { - return false; - } - for (let index = 0; index < a.length; index++) { - const value = a[index]; - if (value !== b[index]) { - return false; - } - } - return true; - }; - const arrayStartsWith = function () { - let a = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : []; - let b = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : []; - return arraysAreEqual(a.slice(0, b.length), b); - }; - const spliceArray = function (array) { - const result = array.slice(0); - for (var _len = arguments.length, args = new Array(_len > 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) { - args[_key - 1] = arguments[_key]; - } - result.splice(...args); - return result; - }; - const summarizeArrayChange = function () { - let oldArray = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : []; - let newArray = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : []; - const added = []; - const removed = []; - const existingValues = new Set(); - oldArray.forEach(value => { - existingValues.add(value); - }); - const currentValues = new Set(); - newArray.forEach(value => { - currentValues.add(value); - if (!existingValues.has(value)) { - added.push(value); - } - }); - oldArray.forEach(value => { - if (!currentValues.has(value)) { - removed.push(value); - } - }); - return { - added, - removed - }; - }; - - // https://github.com/mathiasbynens/unicode-2.1.8/blob/master/Bidi_Class/Right_To_Left/regex.js - const RTL_PATTERN = /[\u05BE\u05C0\u05C3\u05D0-\u05EA\u05F0-\u05F4\u061B\u061F\u0621-\u063A\u0640-\u064A\u066D\u0671-\u06B7\u06BA-\u06BE\u06C0-\u06CE\u06D0-\u06D5\u06E5\u06E6\u200F\u202B\u202E\uFB1F-\uFB28\uFB2A-\uFB36\uFB38-\uFB3C\uFB3E\uFB40\uFB41\uFB43\uFB44\uFB46-\uFBB1\uFBD3-\uFD3D\uFD50-\uFD8F\uFD92-\uFDC7\uFDF0-\uFDFB\uFE70-\uFE72\uFE74\uFE76-\uFEFC]/; - const getDirection = function () { - const input = makeElement("input", { - dir: "auto", - name: "x", - dirName: "x.dir" - }); - const textArea = makeElement("textarea", { - dir: "auto", - name: "y", - dirName: "y.dir" - }); - const form = makeElement("form"); - form.appendChild(input); - form.appendChild(textArea); - const supportsDirName = function () { - try { - return new FormData(form).has(textArea.dirName); - } catch (error) { - return false; - } - }(); - const supportsDirSelector = function () { - try { - return input.matches(":dir(ltr),:dir(rtl)"); - } catch (error) { - return false; - } - }(); - if (supportsDirName) { - return function (string) { - textArea.value = string; - return new FormData(form).get(textArea.dirName); - }; - } else if (supportsDirSelector) { - return function (string) { - input.value = string; - if (input.matches(":dir(rtl)")) { - return "rtl"; - } else { - return "ltr"; - } - }; - } else { - return function (string) { - const char = string.trim().charAt(0); - if (RTL_PATTERN.test(char)) { - return "rtl"; - } else { - return "ltr"; - } - }; - } - }(); - - let allAttributeNames = null; - let blockAttributeNames = null; - let textAttributeNames = null; - let listAttributeNames = null; - const getAllAttributeNames = () => { - if (!allAttributeNames) { - allAttributeNames = getTextAttributeNames().concat(getBlockAttributeNames()); - } - return allAttributeNames; - }; - const getBlockConfig = attributeName => attributes[attributeName]; - const getBlockAttributeNames = () => { - if (!blockAttributeNames) { - blockAttributeNames = Object.keys(attributes); - } - return blockAttributeNames; - }; - const getTextConfig = attributeName => text_attributes[attributeName]; - const getTextAttributeNames = () => { - if (!textAttributeNames) { - textAttributeNames = Object.keys(text_attributes); - } - return textAttributeNames; - }; - const getListAttributeNames = () => { - if (!listAttributeNames) { - listAttributeNames = []; - for (const key in attributes) { - const { - listAttribute - } = attributes[key]; - if (listAttribute != null) { - listAttributeNames.push(listAttribute); - } - } - } - return listAttributeNames; - }; - - /* eslint-disable - */ - const installDefaultCSSForTagName = function (tagName, defaultCSS) { - const styleElement = insertStyleElementForTagName(tagName); - styleElement.textContent = defaultCSS.replace(/%t/g, tagName); - }; - const insertStyleElementForTagName = function (tagName) { - const element = document.createElement("style"); - element.setAttribute("type", "text/css"); - element.setAttribute("data-tag-name", tagName.toLowerCase()); - const nonce = getCSPNonce(); - if (nonce) { - element.setAttribute("nonce", nonce); - } - document.head.insertBefore(element, document.head.firstChild); - return element; - }; - const getCSPNonce = function () { - const element = getMetaElement("trix-csp-nonce") || getMetaElement("csp-nonce"); - if (element) { - const { - nonce, - content - } = element; - return nonce == "" ? content : nonce; - } - }; - const getMetaElement = name => document.head.querySelector("meta[name=".concat(name, "]")); - - const testTransferData = { - "application/x-trix-feature-detection": "test" - }; - const dataTransferIsPlainText = function (dataTransfer) { - const text = dataTransfer.getData("text/plain"); - const html = dataTransfer.getData("text/html"); - if (text && html) { - const { - body - } = new DOMParser().parseFromString(html, "text/html"); - if (body.textContent === text) { - return !body.querySelector("*"); - } - } else { - return text === null || text === void 0 ? void 0 : text.length; - } - }; - const dataTransferIsMsOfficePaste = _ref => { - let { - dataTransfer - } = _ref; - return dataTransfer.types.includes("Files") && dataTransfer.types.includes("text/html") && dataTransfer.getData("text/html").includes("urn:schemas-microsoft-com:office:office"); - }; - const dataTransferIsWritable = function (dataTransfer) { - if (!(dataTransfer !== null && dataTransfer !== void 0 && dataTransfer.setData)) return false; - for (const key in testTransferData) { - const value = testTransferData[key]; - try { - dataTransfer.setData(key, value); - if (!dataTransfer.getData(key) === value) return false; - } catch (error) { - return false; - } - } - return true; - }; - const keyEventIsKeyboardCommand = function () { - if (/Mac|^iP/.test(navigator.platform)) { - return event => event.metaKey; - } else { - return event => event.ctrlKey; - } - }(); - function shouldRenderInmmediatelyToDealWithIOSDictation(inputEvent) { - if (/iPhone|iPad/.test(navigator.userAgent)) { - // Handle garbled content and duplicated newlines when using dictation on iOS 18+. Upon dictation completion, iOS sends - // the list of insertText / insertParagraph events in a quick sequence. If we don't render - // the editor synchronously, the internal range fails to update and results in garbled content or duplicated newlines. - // - // This workaround is necessary because iOS doesn't send composing events as expected while dictating: - // https://bugs.webkit.org/show_bug.cgi?id=261764 - return !inputEvent.inputType || inputEvent.inputType === "insertParagraph"; - } else { - return false; - } - } - - const defer = fn => setTimeout(fn, 1); - - /* eslint-disable - id-length, - */ - const copyObject = function () { - let object = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; - const result = {}; - for (const key in object) { - const value = object[key]; - result[key] = value; - } - return result; - }; - const objectsAreEqual = function () { - let a = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; - let b = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; - if (Object.keys(a).length !== Object.keys(b).length) { - return false; - } - for (const key in a) { - const value = a[key]; - if (value !== b[key]) { - return false; - } - } - return true; - }; - - const normalizeRange = function (range) { - if (range == null) return; - if (!Array.isArray(range)) { - range = [range, range]; - } - return [copyValue(range[0]), copyValue(range[1] != null ? range[1] : range[0])]; - }; - const rangeIsCollapsed = function (range) { - if (range == null) return; - const [start, end] = normalizeRange(range); - return rangeValuesAreEqual(start, end); - }; - const rangesAreEqual = function (leftRange, rightRange) { - if (leftRange == null || rightRange == null) return; - const [leftStart, leftEnd] = normalizeRange(leftRange); - const [rightStart, rightEnd] = normalizeRange(rightRange); - return rangeValuesAreEqual(leftStart, rightStart) && rangeValuesAreEqual(leftEnd, rightEnd); - }; - const copyValue = function (value) { - if (typeof value === "number") { - return value; - } else { - return copyObject(value); - } - }; - const rangeValuesAreEqual = function (left, right) { - if (typeof left === "number") { - return left === right; - } else { - return objectsAreEqual(left, right); - } - }; - - class SelectionChangeObserver extends BasicObject { - constructor() { - super(...arguments); - this.update = this.update.bind(this); - this.selectionManagers = []; - } - start() { - if (!this.started) { - this.started = true; - document.addEventListener("selectionchange", this.update, true); - } - } - stop() { - if (this.started) { - this.started = false; - return document.removeEventListener("selectionchange", this.update, true); - } - } - registerSelectionManager(selectionManager) { - if (!this.selectionManagers.includes(selectionManager)) { - this.selectionManagers.push(selectionManager); - return this.start(); - } - } - unregisterSelectionManager(selectionManager) { - this.selectionManagers = this.selectionManagers.filter(sm => sm !== selectionManager); - if (this.selectionManagers.length === 0) { - return this.stop(); - } - } - notifySelectionManagersOfSelectionChange() { - return this.selectionManagers.map(selectionManager => selectionManager.selectionDidChange()); - } - update() { - this.notifySelectionManagersOfSelectionChange(); - } - reset() { - this.update(); - } - } - const selectionChangeObserver = new SelectionChangeObserver(); - const getDOMSelection = function () { - const selection = window.getSelection(); - if (selection.rangeCount > 0) { - return selection; - } - }; - const getDOMRange = function () { - var _getDOMSelection; - const domRange = (_getDOMSelection = getDOMSelection()) === null || _getDOMSelection === void 0 ? void 0 : _getDOMSelection.getRangeAt(0); - if (domRange) { - if (!domRangeIsPrivate(domRange)) { - return domRange; - } - } - }; - const setDOMRange = function (domRange) { - const selection = window.getSelection(); - selection.removeAllRanges(); - selection.addRange(domRange); - return selectionChangeObserver.update(); - }; - - // In Firefox, clicking certain elements changes the selection to a - // private element used to draw its UI. Attempting to access properties of those - // elements throws an error. - // https://bugzilla.mozilla.org/show_bug.cgi?id=208427 - const domRangeIsPrivate = domRange => nodeIsPrivate(domRange.startContainer) || nodeIsPrivate(domRange.endContainer); - const nodeIsPrivate = node => !Object.getPrototypeOf(node); - - /* eslint-disable - id-length, - no-useless-escape, - */ - const normalizeSpaces = string => string.replace(new RegExp("".concat(ZERO_WIDTH_SPACE), "g"), "").replace(new RegExp("".concat(NON_BREAKING_SPACE), "g"), " "); - const normalizeNewlines = string => string.replace(/\r\n?/g, "\n"); - const breakableWhitespacePattern = new RegExp("[^\\S".concat(NON_BREAKING_SPACE, "]")); - const squishBreakableWhitespace = string => string - // Replace all breakable whitespace characters with a space - .replace(new RegExp("".concat(breakableWhitespacePattern.source), "g"), " ") - // Replace two or more spaces with a single space - .replace(/\ {2,}/g, " "); - const summarizeStringChange = function (oldString, newString) { - let added, removed; - oldString = UTF16String.box(oldString); - newString = UTF16String.box(newString); - if (newString.length < oldString.length) { - [removed, added] = utf16StringDifferences(oldString, newString); - } else { - [added, removed] = utf16StringDifferences(newString, oldString); - } - return { - added, - removed - }; - }; - const utf16StringDifferences = function (a, b) { - if (a.isEqualTo(b)) { - return ["", ""]; - } - const diffA = utf16StringDifference(a, b); - const { - length - } = diffA.utf16String; - let diffB; - if (length) { - const { - offset - } = diffA; - const codepoints = a.codepoints.slice(0, offset).concat(a.codepoints.slice(offset + length)); - diffB = utf16StringDifference(b, UTF16String.fromCodepoints(codepoints)); - } else { - diffB = utf16StringDifference(b, a); - } - return [diffA.utf16String.toString(), diffB.utf16String.toString()]; - }; - const utf16StringDifference = function (a, b) { - let leftIndex = 0; - let rightIndexA = a.length; - let rightIndexB = b.length; - while (leftIndex < rightIndexA && a.charAt(leftIndex).isEqualTo(b.charAt(leftIndex))) { - leftIndex++; - } - while (rightIndexA > leftIndex + 1 && a.charAt(rightIndexA - 1).isEqualTo(b.charAt(rightIndexB - 1))) { - rightIndexA--; - rightIndexB--; - } - return { - utf16String: a.slice(leftIndex, rightIndexA), - offset: leftIndex - }; - }; - - class Hash extends TrixObject { - static fromCommonAttributesOfObjects() { - let objects = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : []; - if (!objects.length) { - return new this(); - } - let hash = box(objects[0]); - let keys = hash.getKeys(); - objects.slice(1).forEach(object => { - keys = hash.getKeysCommonToHash(box(object)); - hash = hash.slice(keys); - }); - return hash; - } - static box(values) { - return box(values); - } - constructor() { - let values = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; - super(...arguments); - this.values = copy(values); - } - add(key, value) { - return this.merge(object(key, value)); - } - remove(key) { - return new Hash(copy(this.values, key)); - } - get(key) { - return this.values[key]; - } - has(key) { - return key in this.values; - } - merge(values) { - return new Hash(merge(this.values, unbox(values))); - } - slice(keys) { - const values = {}; - Array.from(keys).forEach(key => { - if (this.has(key)) { - values[key] = this.values[key]; - } - }); - return new Hash(values); - } - getKeys() { - return Object.keys(this.values); - } - getKeysCommonToHash(hash) { - hash = box(hash); - return this.getKeys().filter(key => this.values[key] === hash.values[key]); - } - isEqualTo(values) { - return arraysAreEqual(this.toArray(), box(values).toArray()); - } - isEmpty() { - return this.getKeys().length === 0; - } - toArray() { - if (!this.array) { - const result = []; - for (const key in this.values) { - const value = this.values[key]; - result.push(result.push(key, value)); - } - this.array = result.slice(0); - } - return this.array; - } - toObject() { - return copy(this.values); - } - toJSON() { - return this.toObject(); - } - contentsForInspection() { - return { - values: JSON.stringify(this.values) - }; - } - } - const object = function (key, value) { - const result = {}; - result[key] = value; - return result; - }; - const merge = function (object, values) { - const result = copy(object); - for (const key in values) { - const value = values[key]; - result[key] = value; - } - return result; - }; - const copy = function (object, keyToRemove) { - const result = {}; - const sortedKeys = Object.keys(object).sort(); - sortedKeys.forEach(key => { - if (key !== keyToRemove) { - result[key] = object[key]; - } - }); - return result; - }; - const box = function (object) { - if (object instanceof Hash) { - return object; - } else { - return new Hash(object); - } - }; - const unbox = function (object) { - if (object instanceof Hash) { - return object.values; - } else { - return object; - } - }; - - class ObjectGroup { - static groupObjects() { - let ungroupedObjects = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : []; - let { - depth, - asTree - } = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; - let group; - if (asTree) { - if (depth == null) { - depth = 0; - } - } - const objects = []; - Array.from(ungroupedObjects).forEach(object => { - var _object$canBeGrouped2; - if (group) { - var _object$canBeGrouped, _group$canBeGroupedWi, _group; - if ((_object$canBeGrouped = object.canBeGrouped) !== null && _object$canBeGrouped !== void 0 && _object$canBeGrouped.call(object, depth) && (_group$canBeGroupedWi = (_group = group[group.length - 1]).canBeGroupedWith) !== null && _group$canBeGroupedWi !== void 0 && _group$canBeGroupedWi.call(_group, object, depth)) { - group.push(object); - return; - } else { - objects.push(new this(group, { - depth, - asTree - })); - group = null; - } - } - if ((_object$canBeGrouped2 = object.canBeGrouped) !== null && _object$canBeGrouped2 !== void 0 && _object$canBeGrouped2.call(object, depth)) { - group = [object]; - } else { - objects.push(object); - } - }); - if (group) { - objects.push(new this(group, { - depth, - asTree - })); - } - return objects; - } - constructor() { - let objects = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : []; - let { - depth, - asTree - } = arguments.length > 1 ? arguments[1] : undefined; - this.objects = objects; - if (asTree) { - this.depth = depth; - this.objects = this.constructor.groupObjects(this.objects, { - asTree, - depth: this.depth + 1 - }); - } - } - getObjects() { - return this.objects; - } - getDepth() { - return this.depth; - } - getCacheKey() { - const keys = ["objectGroup"]; - Array.from(this.getObjects()).forEach(object => { - keys.push(object.getCacheKey()); - }); - return keys.join("/"); - } - } - - class ObjectMap extends BasicObject { - constructor() { - let objects = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : []; - super(...arguments); - this.objects = {}; - Array.from(objects).forEach(object => { - const hash = JSON.stringify(object); - if (this.objects[hash] == null) { - this.objects[hash] = object; - } - }); - } - find(object) { - const hash = JSON.stringify(object); - return this.objects[hash]; - } - } - - class ElementStore { - constructor(elements) { - this.reset(elements); - } - add(element) { - const key = getKey(element); - this.elements[key] = element; - } - remove(element) { - const key = getKey(element); - const value = this.elements[key]; - if (value) { - delete this.elements[key]; - return value; - } - } - reset() { - let elements = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : []; - this.elements = {}; - Array.from(elements).forEach(element => { - this.add(element); - }); - return elements; - } - } - const getKey = element => element.dataset.trixStoreKey; - - class Operation extends BasicObject { - isPerforming() { - return this.performing === true; - } - hasPerformed() { - return this.performed === true; - } - hasSucceeded() { - return this.performed && this.succeeded; - } - hasFailed() { - return this.performed && !this.succeeded; - } - getPromise() { - if (!this.promise) { - this.promise = new Promise((resolve, reject) => { - this.performing = true; - return this.perform((succeeded, result) => { - this.succeeded = succeeded; - this.performing = false; - this.performed = true; - if (this.succeeded) { - resolve(result); - } else { - reject(result); - } - }); - }); - } - return this.promise; - } - perform(callback) { - return callback(false); - } - release() { - var _this$promise, _this$promise$cancel; - (_this$promise = this.promise) === null || _this$promise === void 0 || (_this$promise$cancel = _this$promise.cancel) === null || _this$promise$cancel === void 0 || _this$promise$cancel.call(_this$promise); - this.promise = null; - this.performing = null; - this.performed = null; - this.succeeded = null; - } - } - Operation.proxyMethod("getPromise().then"); - Operation.proxyMethod("getPromise().catch"); - - class ObjectView extends BasicObject { - constructor(object) { - let options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; - super(...arguments); - this.object = object; - this.options = options; - this.childViews = []; - this.rootView = this; - } - getNodes() { - if (!this.nodes) { - this.nodes = this.createNodes(); - } - return this.nodes.map(node => node.cloneNode(true)); - } - invalidate() { - var _this$parentView; - this.nodes = null; - this.childViews = []; - return (_this$parentView = this.parentView) === null || _this$parentView === void 0 ? void 0 : _this$parentView.invalidate(); - } - invalidateViewForObject(object) { - var _this$findViewForObje; - return (_this$findViewForObje = this.findViewForObject(object)) === null || _this$findViewForObje === void 0 ? void 0 : _this$findViewForObje.invalidate(); - } - findOrCreateCachedChildView(viewClass, object, options) { - let view = this.getCachedViewForObject(object); - if (view) { - this.recordChildView(view); - } else { - view = this.createChildView(...arguments); - this.cacheViewForObject(view, object); - } - return view; - } - createChildView(viewClass, object) { - let options = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {}; - if (object instanceof ObjectGroup) { - options.viewClass = viewClass; - viewClass = ObjectGroupView; - } - const view = new viewClass(object, options); - return this.recordChildView(view); - } - recordChildView(view) { - view.parentView = this; - view.rootView = this.rootView; - this.childViews.push(view); - return view; - } - getAllChildViews() { - let views = []; - this.childViews.forEach(childView => { - views.push(childView); - views = views.concat(childView.getAllChildViews()); - }); - return views; - } - findElement() { - return this.findElementForObject(this.object); - } - findElementForObject(object) { - const id = object === null || object === void 0 ? void 0 : object.id; - if (id) { - return this.rootView.element.querySelector("[data-trix-id='".concat(id, "']")); - } - } - findViewForObject(object) { - for (const view of this.getAllChildViews()) { - if (view.object === object) { - return view; - } - } - } - getViewCache() { - if (this.rootView === this) { - if (this.isViewCachingEnabled()) { - if (!this.viewCache) { - this.viewCache = {}; - } - return this.viewCache; - } - } else { - return this.rootView.getViewCache(); - } - } - isViewCachingEnabled() { - return this.shouldCacheViews !== false; - } - enableViewCaching() { - this.shouldCacheViews = true; - } - disableViewCaching() { - this.shouldCacheViews = false; - } - getCachedViewForObject(object) { - var _this$getViewCache; - return (_this$getViewCache = this.getViewCache()) === null || _this$getViewCache === void 0 ? void 0 : _this$getViewCache[object.getCacheKey()]; - } - cacheViewForObject(view, object) { - const cache = this.getViewCache(); - if (cache) { - cache[object.getCacheKey()] = view; - } - } - garbageCollectCachedViews() { - const cache = this.getViewCache(); - if (cache) { - const views = this.getAllChildViews().concat(this); - const objectKeys = views.map(view => view.object.getCacheKey()); - for (const key in cache) { - if (!objectKeys.includes(key)) { - delete cache[key]; - } - } - } - } - } - class ObjectGroupView extends ObjectView { - constructor() { - super(...arguments); - this.objectGroup = this.object; - this.viewClass = this.options.viewClass; - delete this.options.viewClass; - } - getChildViews() { - if (!this.childViews.length) { - Array.from(this.objectGroup.getObjects()).forEach(object => { - this.findOrCreateCachedChildView(this.viewClass, object, this.options); - }); - } - return this.childViews; - } - createNodes() { - const element = this.createContainerElement(); - this.getChildViews().forEach(view => { - Array.from(view.getNodes()).forEach(node => { - element.appendChild(node); - }); - }); - return [element]; - } - createContainerElement() { - let depth = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : this.objectGroup.getDepth(); - return this.getChildViews()[0].createContainerElement(depth); - } - } - - /*! @license DOMPurify 3.2.5 | (c) Cure53 and other contributors | Released under the Apache license 2.0 and Mozilla Public License 2.0 | github.com/cure53/DOMPurify/blob/3.2.5/LICENSE */ - - const { - entries, - setPrototypeOf, - isFrozen, - getPrototypeOf, - getOwnPropertyDescriptor - } = Object; - let { - freeze, - seal, - create - } = Object; // eslint-disable-line import/no-mutable-exports - let { - apply, - construct - } = typeof Reflect !== 'undefined' && Reflect; - if (!freeze) { - freeze = function freeze(x) { - return x; - }; - } - if (!seal) { - seal = function seal(x) { - return x; - }; - } - if (!apply) { - apply = function apply(fun, thisValue, args) { - return fun.apply(thisValue, args); - }; - } - if (!construct) { - construct = function construct(Func, args) { - return new Func(...args); - }; - } - const arrayForEach = unapply(Array.prototype.forEach); - const arrayLastIndexOf = unapply(Array.prototype.lastIndexOf); - const arrayPop = unapply(Array.prototype.pop); - const arrayPush = unapply(Array.prototype.push); - const arraySplice = unapply(Array.prototype.splice); - const stringToLowerCase = unapply(String.prototype.toLowerCase); - const stringToString = unapply(String.prototype.toString); - const stringMatch = unapply(String.prototype.match); - const stringReplace = unapply(String.prototype.replace); - const stringIndexOf = unapply(String.prototype.indexOf); - const stringTrim = unapply(String.prototype.trim); - const objectHasOwnProperty = unapply(Object.prototype.hasOwnProperty); - const regExpTest = unapply(RegExp.prototype.test); - const typeErrorCreate = unconstruct(TypeError); - /** - * Creates a new function that calls the given function with a specified thisArg and arguments. - * - * @param func - The function to be wrapped and called. - * @returns A new function that calls the given function with a specified thisArg and arguments. - */ - function unapply(func) { - return function (thisArg) { - if (thisArg instanceof RegExp) { - thisArg.lastIndex = 0; - } - for (var _len = arguments.length, args = new Array(_len > 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) { - args[_key - 1] = arguments[_key]; - } - return apply(func, thisArg, args); - }; - } - /** - * Creates a new function that constructs an instance of the given constructor function with the provided arguments. - * - * @param func - The constructor function to be wrapped and called. - * @returns A new function that constructs an instance of the given constructor function with the provided arguments. - */ - function unconstruct(func) { - return function () { - for (var _len2 = arguments.length, args = new Array(_len2), _key2 = 0; _key2 < _len2; _key2++) { - args[_key2] = arguments[_key2]; - } - return construct(func, args); - }; - } - /** - * Add properties to a lookup table - * - * @param set - The set to which elements will be added. - * @param array - The array containing elements to be added to the set. - * @param transformCaseFunc - An optional function to transform the case of each element before adding to the set. - * @returns The modified set with added elements. - */ - function addToSet(set, array) { - let transformCaseFunc = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : stringToLowerCase; - if (setPrototypeOf) { - // Make 'in' and truthy checks like Boolean(set.constructor) - // independent of any properties defined on Object.prototype. - // Prevent prototype setters from intercepting set as a this value. - setPrototypeOf(set, null); - } - let l = array.length; - while (l--) { - let element = array[l]; - if (typeof element === 'string') { - const lcElement = transformCaseFunc(element); - if (lcElement !== element) { - // Config presets (e.g. tags.js, attrs.js) are immutable. - if (!isFrozen(array)) { - array[l] = lcElement; - } - element = lcElement; - } - } - set[element] = true; - } - return set; - } - /** - * Clean up an array to harden against CSPP - * - * @param array - The array to be cleaned. - * @returns The cleaned version of the array - */ - function cleanArray(array) { - for (let index = 0; index < array.length; index++) { - const isPropertyExist = objectHasOwnProperty(array, index); - if (!isPropertyExist) { - array[index] = null; - } - } - return array; - } - /** - * Shallow clone an object - * - * @param object - The object to be cloned. - * @returns A new object that copies the original. - */ - function clone(object) { - const newObject = create(null); - for (const [property, value] of entries(object)) { - const isPropertyExist = objectHasOwnProperty(object, property); - if (isPropertyExist) { - if (Array.isArray(value)) { - newObject[property] = cleanArray(value); - } else if (value && typeof value === 'object' && value.constructor === Object) { - newObject[property] = clone(value); - } else { - newObject[property] = value; - } - } - } - return newObject; - } - /** - * This method automatically checks if the prop is function or getter and behaves accordingly. - * - * @param object - The object to look up the getter function in its prototype chain. - * @param prop - The property name for which to find the getter function. - * @returns The getter function found in the prototype chain or a fallback function. - */ - function lookupGetter(object, prop) { - while (object !== null) { - const desc = getOwnPropertyDescriptor(object, prop); - if (desc) { - if (desc.get) { - return unapply(desc.get); - } - if (typeof desc.value === 'function') { - return unapply(desc.value); - } - } - object = getPrototypeOf(object); - } - function fallbackValue() { - return null; - } - return fallbackValue; - } - const html$1 = freeze(['a', 'abbr', 'acronym', 'address', 'area', 'article', 'aside', 'audio', 'b', 'bdi', 'bdo', 'big', 'blink', 'blockquote', 'body', 'br', 'button', 'canvas', 'caption', 'center', 'cite', 'code', 'col', 'colgroup', 'content', 'data', 'datalist', 'dd', 'decorator', 'del', 'details', 'dfn', 'dialog', 'dir', 'div', 'dl', 'dt', 'element', 'em', 'fieldset', 'figcaption', 'figure', 'font', 'footer', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'head', 'header', 'hgroup', 'hr', 'html', 'i', 'img', 'input', 'ins', 'kbd', 'label', 'legend', 'li', 'main', 'map', 'mark', 'marquee', 'menu', 'menuitem', 'meter', 'nav', 'nobr', 'ol', 'optgroup', 'option', 'output', 'p', 'picture', 'pre', 'progress', 'q', 'rp', 'rt', 'ruby', 's', 'samp', 'section', 'select', 'shadow', 'small', 'source', 'spacer', 'span', 'strike', 'strong', 'style', 'sub', 'summary', 'sup', 'table', 'tbody', 'td', 'template', 'textarea', 'tfoot', 'th', 'thead', 'time', 'tr', 'track', 'tt', 'u', 'ul', 'var', 'video', 'wbr']); - const svg$1 = freeze(['svg', 'a', 'altglyph', 'altglyphdef', 'altglyphitem', 'animatecolor', 'animatemotion', 'animatetransform', 'circle', 'clippath', 'defs', 'desc', 'ellipse', 'filter', 'font', 'g', 'glyph', 'glyphref', 'hkern', 'image', 'line', 'lineargradient', 'marker', 'mask', 'metadata', 'mpath', 'path', 'pattern', 'polygon', 'polyline', 'radialgradient', 'rect', 'stop', 'style', 'switch', 'symbol', 'text', 'textpath', 'title', 'tref', 'tspan', 'view', 'vkern']); - const svgFilters = freeze(['feBlend', 'feColorMatrix', 'feComponentTransfer', 'feComposite', 'feConvolveMatrix', 'feDiffuseLighting', 'feDisplacementMap', 'feDistantLight', 'feDropShadow', 'feFlood', 'feFuncA', 'feFuncB', 'feFuncG', 'feFuncR', 'feGaussianBlur', 'feImage', 'feMerge', 'feMergeNode', 'feMorphology', 'feOffset', 'fePointLight', 'feSpecularLighting', 'feSpotLight', 'feTile', 'feTurbulence']); - // List of SVG elements that are disallowed by default. - // We still need to know them so that we can do namespace - // checks properly in case one wants to add them to - // allow-list. - const svgDisallowed = freeze(['animate', 'color-profile', 'cursor', 'discard', 'font-face', 'font-face-format', 'font-face-name', 'font-face-src', 'font-face-uri', 'foreignobject', 'hatch', 'hatchpath', 'mesh', 'meshgradient', 'meshpatch', 'meshrow', 'missing-glyph', 'script', 'set', 'solidcolor', 'unknown', 'use']); - const mathMl$1 = freeze(['math', 'menclose', 'merror', 'mfenced', 'mfrac', 'mglyph', 'mi', 'mlabeledtr', 'mmultiscripts', 'mn', 'mo', 'mover', 'mpadded', 'mphantom', 'mroot', 'mrow', 'ms', 'mspace', 'msqrt', 'mstyle', 'msub', 'msup', 'msubsup', 'mtable', 'mtd', 'mtext', 'mtr', 'munder', 'munderover', 'mprescripts']); - // Similarly to SVG, we want to know all MathML elements, - // even those that we disallow by default. - const mathMlDisallowed = freeze(['maction', 'maligngroup', 'malignmark', 'mlongdiv', 'mscarries', 'mscarry', 'msgroup', 'mstack', 'msline', 'msrow', 'semantics', 'annotation', 'annotation-xml', 'mprescripts', 'none']); - const text = freeze(['#text']); - const html = freeze(['accept', 'action', 'align', 'alt', 'autocapitalize', 'autocomplete', 'autopictureinpicture', 'autoplay', 'background', 'bgcolor', 'border', 'capture', 'cellpadding', 'cellspacing', 'checked', 'cite', 'class', 'clear', 'color', 'cols', 'colspan', 'controls', 'controlslist', 'coords', 'crossorigin', 'datetime', 'decoding', 'default', 'dir', 'disabled', 'disablepictureinpicture', 'disableremoteplayback', 'download', 'draggable', 'enctype', 'enterkeyhint', 'face', 'for', 'headers', 'height', 'hidden', 'high', 'href', 'hreflang', 'id', 'inputmode', 'integrity', 'ismap', 'kind', 'label', 'lang', 'list', 'loading', 'loop', 'low', 'max', 'maxlength', 'media', 'method', 'min', 'minlength', 'multiple', 'muted', 'name', 'nonce', 'noshade', 'novalidate', 'nowrap', 'open', 'optimum', 'pattern', 'placeholder', 'playsinline', 'popover', 'popovertarget', 'popovertargetaction', 'poster', 'preload', 'pubdate', 'radiogroup', 'readonly', 'rel', 'required', 'rev', 'reversed', 'role', 'rows', 'rowspan', 'spellcheck', 'scope', 'selected', 'shape', 'size', 'sizes', 'span', 'srclang', 'start', 'src', 'srcset', 'step', 'style', 'summary', 'tabindex', 'title', 'translate', 'type', 'usemap', 'valign', 'value', 'width', 'wrap', 'xmlns', 'slot']); - const svg = freeze(['accent-height', 'accumulate', 'additive', 'alignment-baseline', 'amplitude', 'ascent', 'attributename', 'attributetype', 'azimuth', 'basefrequency', 'baseline-shift', 'begin', 'bias', 'by', 'class', 'clip', 'clippathunits', 'clip-path', 'clip-rule', 'color', 'color-interpolation', 'color-interpolation-filters', 'color-profile', 'color-rendering', 'cx', 'cy', 'd', 'dx', 'dy', 'diffuseconstant', 'direction', 'display', 'divisor', 'dur', 'edgemode', 'elevation', 'end', 'exponent', 'fill', 'fill-opacity', 'fill-rule', 'filter', 'filterunits', 'flood-color', 'flood-opacity', 'font-family', 'font-size', 'font-size-adjust', 'font-stretch', 'font-style', 'font-variant', 'font-weight', 'fx', 'fy', 'g1', 'g2', 'glyph-name', 'glyphref', 'gradientunits', 'gradienttransform', 'height', 'href', 'id', 'image-rendering', 'in', 'in2', 'intercept', 'k', 'k1', 'k2', 'k3', 'k4', 'kerning', 'keypoints', 'keysplines', 'keytimes', 'lang', 'lengthadjust', 'letter-spacing', 'kernelmatrix', 'kernelunitlength', 'lighting-color', 'local', 'marker-end', 'marker-mid', 'marker-start', 'markerheight', 'markerunits', 'markerwidth', 'maskcontentunits', 'maskunits', 'max', 'mask', 'media', 'method', 'mode', 'min', 'name', 'numoctaves', 'offset', 'operator', 'opacity', 'order', 'orient', 'orientation', 'origin', 'overflow', 'paint-order', 'path', 'pathlength', 'patterncontentunits', 'patterntransform', 'patternunits', 'points', 'preservealpha', 'preserveaspectratio', 'primitiveunits', 'r', 'rx', 'ry', 'radius', 'refx', 'refy', 'repeatcount', 'repeatdur', 'restart', 'result', 'rotate', 'scale', 'seed', 'shape-rendering', 'slope', 'specularconstant', 'specularexponent', 'spreadmethod', 'startoffset', 'stddeviation', 'stitchtiles', 'stop-color', 'stop-opacity', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-opacity', 'stroke', 'stroke-width', 'style', 'surfacescale', 'systemlanguage', 'tabindex', 'tablevalues', 'targetx', 'targety', 'transform', 'transform-origin', 'text-anchor', 'text-decoration', 'text-rendering', 'textlength', 'type', 'u1', 'u2', 'unicode', 'values', 'viewbox', 'visibility', 'version', 'vert-adv-y', 'vert-origin-x', 'vert-origin-y', 'width', 'word-spacing', 'wrap', 'writing-mode', 'xchannelselector', 'ychannelselector', 'x', 'x1', 'x2', 'xmlns', 'y', 'y1', 'y2', 'z', 'zoomandpan']); - const mathMl = freeze(['accent', 'accentunder', 'align', 'bevelled', 'close', 'columnsalign', 'columnlines', 'columnspan', 'denomalign', 'depth', 'dir', 'display', 'displaystyle', 'encoding', 'fence', 'frame', 'height', 'href', 'id', 'largeop', 'length', 'linethickness', 'lspace', 'lquote', 'mathbackground', 'mathcolor', 'mathsize', 'mathvariant', 'maxsize', 'minsize', 'movablelimits', 'notation', 'numalign', 'open', 'rowalign', 'rowlines', 'rowspacing', 'rowspan', 'rspace', 'rquote', 'scriptlevel', 'scriptminsize', 'scriptsizemultiplier', 'selection', 'separator', 'separators', 'stretchy', 'subscriptshift', 'supscriptshift', 'symmetric', 'voffset', 'width', 'xmlns']); - const xml = freeze(['xlink:href', 'xml:id', 'xlink:title', 'xml:space', 'xmlns:xlink']); - - // eslint-disable-next-line unicorn/better-regex - const MUSTACHE_EXPR = seal(/\{\{[\w\W]*|[\w\W]*\}\}/gm); // Specify template detection regex for SAFE_FOR_TEMPLATES mode - const ERB_EXPR = seal(/<%[\w\W]*|[\w\W]*%>/gm); - const TMPLIT_EXPR = seal(/\$\{[\w\W]*/gm); // eslint-disable-line unicorn/better-regex - const DATA_ATTR = seal(/^data-[\-\w.\u00B7-\uFFFF]+$/); // eslint-disable-line no-useless-escape - const ARIA_ATTR = seal(/^aria-[\-\w]+$/); // eslint-disable-line no-useless-escape - const IS_ALLOWED_URI = seal(/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i // eslint-disable-line no-useless-escape - ); - - const IS_SCRIPT_OR_DATA = seal(/^(?:\w+script|data):/i); - const ATTR_WHITESPACE = seal(/[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g // eslint-disable-line no-control-regex - ); - - const DOCTYPE_NAME = seal(/^html$/i); - const CUSTOM_ELEMENT = seal(/^[a-z][.\w]*(-[.\w]+)+$/i); - var EXPRESSIONS = /*#__PURE__*/Object.freeze({ - __proto__: null, - ARIA_ATTR: ARIA_ATTR, - ATTR_WHITESPACE: ATTR_WHITESPACE, - CUSTOM_ELEMENT: CUSTOM_ELEMENT, - DATA_ATTR: DATA_ATTR, - DOCTYPE_NAME: DOCTYPE_NAME, - ERB_EXPR: ERB_EXPR, - IS_ALLOWED_URI: IS_ALLOWED_URI, - IS_SCRIPT_OR_DATA: IS_SCRIPT_OR_DATA, - MUSTACHE_EXPR: MUSTACHE_EXPR, - TMPLIT_EXPR: TMPLIT_EXPR - }); - - /* eslint-disable @typescript-eslint/indent */ - // https://developer.mozilla.org/en-US/docs/Web/API/Node/nodeType - const NODE_TYPE = { - element: 1, - attribute: 2, - text: 3, - cdataSection: 4, - entityReference: 5, - // Deprecated - entityNode: 6, - // Deprecated - progressingInstruction: 7, - comment: 8, - document: 9, - documentType: 10, - documentFragment: 11, - notation: 12 // Deprecated - }; - - const getGlobal = function getGlobal() { - return typeof window === 'undefined' ? null : window; - }; - /** - * Creates a no-op policy for internal use only. - * Don't export this function outside this module! - * @param trustedTypes The policy factory. - * @param purifyHostElement The Script element used to load DOMPurify (to determine policy name suffix). - * @return The policy created (or null, if Trusted Types - * are not supported or creating the policy failed). - */ - const _createTrustedTypesPolicy = function _createTrustedTypesPolicy(trustedTypes, purifyHostElement) { - if (typeof trustedTypes !== 'object' || typeof trustedTypes.createPolicy !== 'function') { - return null; - } - // Allow the callers to control the unique policy name - // by adding a data-tt-policy-suffix to the script element with the DOMPurify. - // Policy creation with duplicate names throws in Trusted Types. - let suffix = null; - const ATTR_NAME = 'data-tt-policy-suffix'; - if (purifyHostElement && purifyHostElement.hasAttribute(ATTR_NAME)) { - suffix = purifyHostElement.getAttribute(ATTR_NAME); - } - const policyName = 'dompurify' + (suffix ? '#' + suffix : ''); - try { - return trustedTypes.createPolicy(policyName, { - createHTML(html) { - return html; - }, - createScriptURL(scriptUrl) { - return scriptUrl; - } - }); - } catch (_) { - // Policy creation failed (most likely another DOMPurify script has - // already run). Skip creating the policy, as this will only cause errors - // if TT are enforced. - console.warn('TrustedTypes policy ' + policyName + ' could not be created.'); - return null; - } - }; - const _createHooksMap = function _createHooksMap() { - return { - afterSanitizeAttributes: [], - afterSanitizeElements: [], - afterSanitizeShadowDOM: [], - beforeSanitizeAttributes: [], - beforeSanitizeElements: [], - beforeSanitizeShadowDOM: [], - uponSanitizeAttribute: [], - uponSanitizeElement: [], - uponSanitizeShadowNode: [] - }; - }; - function createDOMPurify() { - let window = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : getGlobal(); - const DOMPurify = root => createDOMPurify(root); - DOMPurify.version = '3.2.5'; - DOMPurify.removed = []; - if (!window || !window.document || window.document.nodeType !== NODE_TYPE.document || !window.Element) { - // Not running in a browser, provide a factory function - // so that you can pass your own Window - DOMPurify.isSupported = false; - return DOMPurify; - } - let { - document - } = window; - const originalDocument = document; - const currentScript = originalDocument.currentScript; - const { - DocumentFragment, - HTMLTemplateElement, - Node, - Element, - NodeFilter, - NamedNodeMap = window.NamedNodeMap || window.MozNamedAttrMap, - HTMLFormElement, - DOMParser, - trustedTypes - } = window; - const ElementPrototype = Element.prototype; - const cloneNode = lookupGetter(ElementPrototype, 'cloneNode'); - const remove = lookupGetter(ElementPrototype, 'remove'); - const getNextSibling = lookupGetter(ElementPrototype, 'nextSibling'); - const getChildNodes = lookupGetter(ElementPrototype, 'childNodes'); - const getParentNode = lookupGetter(ElementPrototype, 'parentNode'); - // As per issue #47, the web-components registry is inherited by a - // new document created via createHTMLDocument. As per the spec - // (http://w3c.github.io/webcomponents/spec/custom/#creating-and-passing-registries) - // a new empty registry is used when creating a template contents owner - // document, so we use that as our parent document to ensure nothing - // is inherited. - if (typeof HTMLTemplateElement === 'function') { - const template = document.createElement('template'); - if (template.content && template.content.ownerDocument) { - document = template.content.ownerDocument; - } - } - let trustedTypesPolicy; - let emptyHTML = ''; - const { - implementation, - createNodeIterator, - createDocumentFragment, - getElementsByTagName - } = document; - const { - importNode - } = originalDocument; - let hooks = _createHooksMap(); - /** - * Expose whether this browser supports running the full DOMPurify. - */ - DOMPurify.isSupported = typeof entries === 'function' && typeof getParentNode === 'function' && implementation && implementation.createHTMLDocument !== undefined; - const { - MUSTACHE_EXPR, - ERB_EXPR, - TMPLIT_EXPR, - DATA_ATTR, - ARIA_ATTR, - IS_SCRIPT_OR_DATA, - ATTR_WHITESPACE, - CUSTOM_ELEMENT - } = EXPRESSIONS; - let { - IS_ALLOWED_URI: IS_ALLOWED_URI$1 - } = EXPRESSIONS; - /** - * We consider the elements and attributes below to be safe. Ideally - * don't add any new ones but feel free to remove unwanted ones. - */ - /* allowed element names */ - let ALLOWED_TAGS = null; - const DEFAULT_ALLOWED_TAGS = addToSet({}, [...html$1, ...svg$1, ...svgFilters, ...mathMl$1, ...text]); - /* Allowed attribute names */ - let ALLOWED_ATTR = null; - const DEFAULT_ALLOWED_ATTR = addToSet({}, [...html, ...svg, ...mathMl, ...xml]); - /* - * Configure how DOMPurify should handle custom elements and their attributes as well as customized built-in elements. - * @property {RegExp|Function|null} tagNameCheck one of [null, regexPattern, predicate]. Default: `null` (disallow any custom elements) - * @property {RegExp|Function|null} attributeNameCheck one of [null, regexPattern, predicate]. Default: `null` (disallow any attributes not on the allow list) - * @property {boolean} allowCustomizedBuiltInElements allow custom elements derived from built-ins if they pass CUSTOM_ELEMENT_HANDLING.tagNameCheck. Default: `false`. - */ - let CUSTOM_ELEMENT_HANDLING = Object.seal(create(null, { - tagNameCheck: { - writable: true, - configurable: false, - enumerable: true, - value: null - }, - attributeNameCheck: { - writable: true, - configurable: false, - enumerable: true, - value: null - }, - allowCustomizedBuiltInElements: { - writable: true, - configurable: false, - enumerable: true, - value: false - } - })); - /* Explicitly forbidden tags (overrides ALLOWED_TAGS/ADD_TAGS) */ - let FORBID_TAGS = null; - /* Explicitly forbidden attributes (overrides ALLOWED_ATTR/ADD_ATTR) */ - let FORBID_ATTR = null; - /* Decide if ARIA attributes are okay */ - let ALLOW_ARIA_ATTR = true; - /* Decide if custom data attributes are okay */ - let ALLOW_DATA_ATTR = true; - /* Decide if unknown protocols are okay */ - let ALLOW_UNKNOWN_PROTOCOLS = false; - /* Decide if self-closing tags in attributes are allowed. - * Usually removed due to a mXSS issue in jQuery 3.0 */ - let ALLOW_SELF_CLOSE_IN_ATTR = true; - /* Output should be safe for common template engines. - * This means, DOMPurify removes data attributes, mustaches and ERB - */ - let SAFE_FOR_TEMPLATES = false; - /* Output should be safe even for XML used within HTML and alike. - * This means, DOMPurify removes comments when containing risky content. - */ - let SAFE_FOR_XML = true; - /* Decide if document with ... should be returned */ - let WHOLE_DOCUMENT = false; - /* Track whether config is already set on this instance of DOMPurify. */ - let SET_CONFIG = false; - /* Decide if all elements (e.g. style, script) must be children of - * document.body. By default, browsers might move them to document.head */ - let FORCE_BODY = false; - /* Decide if a DOM `HTMLBodyElement` should be returned, instead of a html - * string (or a TrustedHTML object if Trusted Types are supported). - * If `WHOLE_DOCUMENT` is enabled a `HTMLHtmlElement` will be returned instead - */ - let RETURN_DOM = false; - /* Decide if a DOM `DocumentFragment` should be returned, instead of a html - * string (or a TrustedHTML object if Trusted Types are supported) */ - let RETURN_DOM_FRAGMENT = false; - /* Try to return a Trusted Type object instead of a string, return a string in - * case Trusted Types are not supported */ - let RETURN_TRUSTED_TYPE = false; - /* Output should be free from DOM clobbering attacks? - * This sanitizes markups named with colliding, clobberable built-in DOM APIs. - */ - let SANITIZE_DOM = true; - /* Achieve full DOM Clobbering protection by isolating the namespace of named - * properties and JS variables, mitigating attacks that abuse the HTML/DOM spec rules. - * - * HTML/DOM spec rules that enable DOM Clobbering: - * - Named Access on Window (§7.3.3) - * - DOM Tree Accessors (§3.1.5) - * - Form Element Parent-Child Relations (§4.10.3) - * - Iframe srcdoc / Nested WindowProxies (§4.8.5) - * - HTMLCollection (§4.2.10.2) - * - * Namespace isolation is implemented by prefixing `id` and `name` attributes - * with a constant string, i.e., `user-content-` - */ - let SANITIZE_NAMED_PROPS = false; - const SANITIZE_NAMED_PROPS_PREFIX = 'user-content-'; - /* Keep element content when removing element? */ - let KEEP_CONTENT = true; - /* If a `Node` is passed to sanitize(), then performs sanitization in-place instead - * of importing it into a new Document and returning a sanitized copy */ - let IN_PLACE = false; - /* Allow usage of profiles like html, svg and mathMl */ - let USE_PROFILES = {}; - /* Tags to ignore content of when KEEP_CONTENT is true */ - let FORBID_CONTENTS = null; - const DEFAULT_FORBID_CONTENTS = addToSet({}, ['annotation-xml', 'audio', 'colgroup', 'desc', 'foreignobject', 'head', 'iframe', 'math', 'mi', 'mn', 'mo', 'ms', 'mtext', 'noembed', 'noframes', 'noscript', 'plaintext', 'script', 'style', 'svg', 'template', 'thead', 'title', 'video', 'xmp']); - /* Tags that are safe for data: URIs */ - let DATA_URI_TAGS = null; - const DEFAULT_DATA_URI_TAGS = addToSet({}, ['audio', 'video', 'img', 'source', 'image', 'track']); - /* Attributes safe for values like "javascript:" */ - let URI_SAFE_ATTRIBUTES = null; - const DEFAULT_URI_SAFE_ATTRIBUTES = addToSet({}, ['alt', 'class', 'for', 'id', 'label', 'name', 'pattern', 'placeholder', 'role', 'summary', 'title', 'value', 'style', 'xmlns']); - const MATHML_NAMESPACE = 'http://www.w3.org/1998/Math/MathML'; - const SVG_NAMESPACE = 'http://www.w3.org/2000/svg'; - const HTML_NAMESPACE = 'http://www.w3.org/1999/xhtml'; - /* Document namespace */ - let NAMESPACE = HTML_NAMESPACE; - let IS_EMPTY_INPUT = false; - /* Allowed XHTML+XML namespaces */ - let ALLOWED_NAMESPACES = null; - const DEFAULT_ALLOWED_NAMESPACES = addToSet({}, [MATHML_NAMESPACE, SVG_NAMESPACE, HTML_NAMESPACE], stringToString); - let MATHML_TEXT_INTEGRATION_POINTS = addToSet({}, ['mi', 'mo', 'mn', 'ms', 'mtext']); - let HTML_INTEGRATION_POINTS = addToSet({}, ['annotation-xml']); - // Certain elements are allowed in both SVG and HTML - // namespace. We need to specify them explicitly - // so that they don't get erroneously deleted from - // HTML namespace. - const COMMON_SVG_AND_HTML_ELEMENTS = addToSet({}, ['title', 'style', 'font', 'a', 'script']); - /* Parsing of strict XHTML documents */ - let PARSER_MEDIA_TYPE = null; - const SUPPORTED_PARSER_MEDIA_TYPES = ['application/xhtml+xml', 'text/html']; - const DEFAULT_PARSER_MEDIA_TYPE = 'text/html'; - let transformCaseFunc = null; - /* Keep a reference to config to pass to hooks */ - let CONFIG = null; - /* Ideally, do not touch anything below this line */ - /* ______________________________________________ */ - const formElement = document.createElement('form'); - const isRegexOrFunction = function isRegexOrFunction(testValue) { - return testValue instanceof RegExp || testValue instanceof Function; - }; - /** - * _parseConfig - * - * @param cfg optional config literal - */ - // eslint-disable-next-line complexity - const _parseConfig = function _parseConfig() { - let cfg = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; - if (CONFIG && CONFIG === cfg) { - return; - } - /* Shield configuration object from tampering */ - if (!cfg || typeof cfg !== 'object') { - cfg = {}; - } - /* Shield configuration object from prototype pollution */ - cfg = clone(cfg); - PARSER_MEDIA_TYPE = - // eslint-disable-next-line unicorn/prefer-includes - SUPPORTED_PARSER_MEDIA_TYPES.indexOf(cfg.PARSER_MEDIA_TYPE) === -1 ? DEFAULT_PARSER_MEDIA_TYPE : cfg.PARSER_MEDIA_TYPE; - // HTML tags and attributes are not case-sensitive, converting to lowercase. Keeping XHTML as is. - transformCaseFunc = PARSER_MEDIA_TYPE === 'application/xhtml+xml' ? stringToString : stringToLowerCase; - /* Set configuration parameters */ - ALLOWED_TAGS = objectHasOwnProperty(cfg, 'ALLOWED_TAGS') ? addToSet({}, cfg.ALLOWED_TAGS, transformCaseFunc) : DEFAULT_ALLOWED_TAGS; - ALLOWED_ATTR = objectHasOwnProperty(cfg, 'ALLOWED_ATTR') ? addToSet({}, cfg.ALLOWED_ATTR, transformCaseFunc) : DEFAULT_ALLOWED_ATTR; - ALLOWED_NAMESPACES = objectHasOwnProperty(cfg, 'ALLOWED_NAMESPACES') ? addToSet({}, cfg.ALLOWED_NAMESPACES, stringToString) : DEFAULT_ALLOWED_NAMESPACES; - URI_SAFE_ATTRIBUTES = objectHasOwnProperty(cfg, 'ADD_URI_SAFE_ATTR') ? addToSet(clone(DEFAULT_URI_SAFE_ATTRIBUTES), cfg.ADD_URI_SAFE_ATTR, transformCaseFunc) : DEFAULT_URI_SAFE_ATTRIBUTES; - DATA_URI_TAGS = objectHasOwnProperty(cfg, 'ADD_DATA_URI_TAGS') ? addToSet(clone(DEFAULT_DATA_URI_TAGS), cfg.ADD_DATA_URI_TAGS, transformCaseFunc) : DEFAULT_DATA_URI_TAGS; - FORBID_CONTENTS = objectHasOwnProperty(cfg, 'FORBID_CONTENTS') ? addToSet({}, cfg.FORBID_CONTENTS, transformCaseFunc) : DEFAULT_FORBID_CONTENTS; - FORBID_TAGS = objectHasOwnProperty(cfg, 'FORBID_TAGS') ? addToSet({}, cfg.FORBID_TAGS, transformCaseFunc) : {}; - FORBID_ATTR = objectHasOwnProperty(cfg, 'FORBID_ATTR') ? addToSet({}, cfg.FORBID_ATTR, transformCaseFunc) : {}; - USE_PROFILES = objectHasOwnProperty(cfg, 'USE_PROFILES') ? cfg.USE_PROFILES : false; - ALLOW_ARIA_ATTR = cfg.ALLOW_ARIA_ATTR !== false; // Default true - ALLOW_DATA_ATTR = cfg.ALLOW_DATA_ATTR !== false; // Default true - ALLOW_UNKNOWN_PROTOCOLS = cfg.ALLOW_UNKNOWN_PROTOCOLS || false; // Default false - ALLOW_SELF_CLOSE_IN_ATTR = cfg.ALLOW_SELF_CLOSE_IN_ATTR !== false; // Default true - SAFE_FOR_TEMPLATES = cfg.SAFE_FOR_TEMPLATES || false; // Default false - SAFE_FOR_XML = cfg.SAFE_FOR_XML !== false; // Default true - WHOLE_DOCUMENT = cfg.WHOLE_DOCUMENT || false; // Default false - RETURN_DOM = cfg.RETURN_DOM || false; // Default false - RETURN_DOM_FRAGMENT = cfg.RETURN_DOM_FRAGMENT || false; // Default false - RETURN_TRUSTED_TYPE = cfg.RETURN_TRUSTED_TYPE || false; // Default false - FORCE_BODY = cfg.FORCE_BODY || false; // Default false - SANITIZE_DOM = cfg.SANITIZE_DOM !== false; // Default true - SANITIZE_NAMED_PROPS = cfg.SANITIZE_NAMED_PROPS || false; // Default false - KEEP_CONTENT = cfg.KEEP_CONTENT !== false; // Default true - IN_PLACE = cfg.IN_PLACE || false; // Default false - IS_ALLOWED_URI$1 = cfg.ALLOWED_URI_REGEXP || IS_ALLOWED_URI; - NAMESPACE = cfg.NAMESPACE || HTML_NAMESPACE; - MATHML_TEXT_INTEGRATION_POINTS = cfg.MATHML_TEXT_INTEGRATION_POINTS || MATHML_TEXT_INTEGRATION_POINTS; - HTML_INTEGRATION_POINTS = cfg.HTML_INTEGRATION_POINTS || HTML_INTEGRATION_POINTS; - CUSTOM_ELEMENT_HANDLING = cfg.CUSTOM_ELEMENT_HANDLING || {}; - if (cfg.CUSTOM_ELEMENT_HANDLING && isRegexOrFunction(cfg.CUSTOM_ELEMENT_HANDLING.tagNameCheck)) { - CUSTOM_ELEMENT_HANDLING.tagNameCheck = cfg.CUSTOM_ELEMENT_HANDLING.tagNameCheck; - } - if (cfg.CUSTOM_ELEMENT_HANDLING && isRegexOrFunction(cfg.CUSTOM_ELEMENT_HANDLING.attributeNameCheck)) { - CUSTOM_ELEMENT_HANDLING.attributeNameCheck = cfg.CUSTOM_ELEMENT_HANDLING.attributeNameCheck; - } - if (cfg.CUSTOM_ELEMENT_HANDLING && typeof cfg.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements === 'boolean') { - CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements = cfg.CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements; - } - if (SAFE_FOR_TEMPLATES) { - ALLOW_DATA_ATTR = false; - } - if (RETURN_DOM_FRAGMENT) { - RETURN_DOM = true; - } - /* Parse profile info */ - if (USE_PROFILES) { - ALLOWED_TAGS = addToSet({}, text); - ALLOWED_ATTR = []; - if (USE_PROFILES.html === true) { - addToSet(ALLOWED_TAGS, html$1); - addToSet(ALLOWED_ATTR, html); - } - if (USE_PROFILES.svg === true) { - addToSet(ALLOWED_TAGS, svg$1); - addToSet(ALLOWED_ATTR, svg); - addToSet(ALLOWED_ATTR, xml); - } - if (USE_PROFILES.svgFilters === true) { - addToSet(ALLOWED_TAGS, svgFilters); - addToSet(ALLOWED_ATTR, svg); - addToSet(ALLOWED_ATTR, xml); - } - if (USE_PROFILES.mathMl === true) { - addToSet(ALLOWED_TAGS, mathMl$1); - addToSet(ALLOWED_ATTR, mathMl); - addToSet(ALLOWED_ATTR, xml); - } - } - /* Merge configuration parameters */ - if (cfg.ADD_TAGS) { - if (ALLOWED_TAGS === DEFAULT_ALLOWED_TAGS) { - ALLOWED_TAGS = clone(ALLOWED_TAGS); - } - addToSet(ALLOWED_TAGS, cfg.ADD_TAGS, transformCaseFunc); - } - if (cfg.ADD_ATTR) { - if (ALLOWED_ATTR === DEFAULT_ALLOWED_ATTR) { - ALLOWED_ATTR = clone(ALLOWED_ATTR); - } - addToSet(ALLOWED_ATTR, cfg.ADD_ATTR, transformCaseFunc); - } - if (cfg.ADD_URI_SAFE_ATTR) { - addToSet(URI_SAFE_ATTRIBUTES, cfg.ADD_URI_SAFE_ATTR, transformCaseFunc); - } - if (cfg.FORBID_CONTENTS) { - if (FORBID_CONTENTS === DEFAULT_FORBID_CONTENTS) { - FORBID_CONTENTS = clone(FORBID_CONTENTS); - } - addToSet(FORBID_CONTENTS, cfg.FORBID_CONTENTS, transformCaseFunc); - } - /* Add #text in case KEEP_CONTENT is set to true */ - if (KEEP_CONTENT) { - ALLOWED_TAGS['#text'] = true; - } - /* Add html, head and body to ALLOWED_TAGS in case WHOLE_DOCUMENT is true */ - if (WHOLE_DOCUMENT) { - addToSet(ALLOWED_TAGS, ['html', 'head', 'body']); - } - /* Add tbody to ALLOWED_TAGS in case tables are permitted, see #286, #365 */ - if (ALLOWED_TAGS.table) { - addToSet(ALLOWED_TAGS, ['tbody']); - delete FORBID_TAGS.tbody; - } - if (cfg.TRUSTED_TYPES_POLICY) { - if (typeof cfg.TRUSTED_TYPES_POLICY.createHTML !== 'function') { - throw typeErrorCreate('TRUSTED_TYPES_POLICY configuration option must provide a "createHTML" hook.'); - } - if (typeof cfg.TRUSTED_TYPES_POLICY.createScriptURL !== 'function') { - throw typeErrorCreate('TRUSTED_TYPES_POLICY configuration option must provide a "createScriptURL" hook.'); - } - // Overwrite existing TrustedTypes policy. - trustedTypesPolicy = cfg.TRUSTED_TYPES_POLICY; - // Sign local variables required by `sanitize`. - emptyHTML = trustedTypesPolicy.createHTML(''); - } else { - // Uninitialized policy, attempt to initialize the internal dompurify policy. - if (trustedTypesPolicy === undefined) { - trustedTypesPolicy = _createTrustedTypesPolicy(trustedTypes, currentScript); - } - // If creating the internal policy succeeded sign internal variables. - if (trustedTypesPolicy !== null && typeof emptyHTML === 'string') { - emptyHTML = trustedTypesPolicy.createHTML(''); - } - } - // Prevent further manipulation of configuration. - // Not available in IE8, Safari 5, etc. - if (freeze) { - freeze(cfg); - } - CONFIG = cfg; - }; - /* Keep track of all possible SVG and MathML tags - * so that we can perform the namespace checks - * correctly. */ - const ALL_SVG_TAGS = addToSet({}, [...svg$1, ...svgFilters, ...svgDisallowed]); - const ALL_MATHML_TAGS = addToSet({}, [...mathMl$1, ...mathMlDisallowed]); - /** - * @param element a DOM element whose namespace is being checked - * @returns Return false if the element has a - * namespace that a spec-compliant parser would never - * return. Return true otherwise. - */ - const _checkValidNamespace = function _checkValidNamespace(element) { - let parent = getParentNode(element); - // In JSDOM, if we're inside shadow DOM, then parentNode - // can be null. We just simulate parent in this case. - if (!parent || !parent.tagName) { - parent = { - namespaceURI: NAMESPACE, - tagName: 'template' - }; - } - const tagName = stringToLowerCase(element.tagName); - const parentTagName = stringToLowerCase(parent.tagName); - if (!ALLOWED_NAMESPACES[element.namespaceURI]) { - return false; - } - if (element.namespaceURI === SVG_NAMESPACE) { - // The only way to switch from HTML namespace to SVG - // is via . If it happens via any other tag, then - // it should be killed. - if (parent.namespaceURI === HTML_NAMESPACE) { - return tagName === 'svg'; - } - // The only way to switch from MathML to SVG is via` - // svg if parent is either or MathML - // text integration points. - if (parent.namespaceURI === MATHML_NAMESPACE) { - return tagName === 'svg' && (parentTagName === 'annotation-xml' || MATHML_TEXT_INTEGRATION_POINTS[parentTagName]); - } - // We only allow elements that are defined in SVG - // spec. All others are disallowed in SVG namespace. - return Boolean(ALL_SVG_TAGS[tagName]); - } - if (element.namespaceURI === MATHML_NAMESPACE) { - // The only way to switch from HTML namespace to MathML - // is via . If it happens via any other tag, then - // it should be killed. - if (parent.namespaceURI === HTML_NAMESPACE) { - return tagName === 'math'; - } - // The only way to switch from SVG to MathML is via - // and HTML integration points - if (parent.namespaceURI === SVG_NAMESPACE) { - return tagName === 'math' && HTML_INTEGRATION_POINTS[parentTagName]; - } - // We only allow elements that are defined in MathML - // spec. All others are disallowed in MathML namespace. - return Boolean(ALL_MATHML_TAGS[tagName]); - } - if (element.namespaceURI === HTML_NAMESPACE) { - // The only way to switch from SVG to HTML is via - // HTML integration points, and from MathML to HTML - // is via MathML text integration points - if (parent.namespaceURI === SVG_NAMESPACE && !HTML_INTEGRATION_POINTS[parentTagName]) { - return false; - } - if (parent.namespaceURI === MATHML_NAMESPACE && !MATHML_TEXT_INTEGRATION_POINTS[parentTagName]) { - return false; - } - // We disallow tags that are specific for MathML - // or SVG and should never appear in HTML namespace - return !ALL_MATHML_TAGS[tagName] && (COMMON_SVG_AND_HTML_ELEMENTS[tagName] || !ALL_SVG_TAGS[tagName]); - } - // For XHTML and XML documents that support custom namespaces - if (PARSER_MEDIA_TYPE === 'application/xhtml+xml' && ALLOWED_NAMESPACES[element.namespaceURI]) { - return true; - } - // The code should never reach this place (this means - // that the element somehow got namespace that is not - // HTML, SVG, MathML or allowed via ALLOWED_NAMESPACES). - // Return false just in case. - return false; - }; - /** - * _forceRemove - * - * @param node a DOM node - */ - const _forceRemove = function _forceRemove(node) { - arrayPush(DOMPurify.removed, { - element: node - }); - try { - // eslint-disable-next-line unicorn/prefer-dom-node-remove - getParentNode(node).removeChild(node); - } catch (_) { - remove(node); - } - }; - /** - * _removeAttribute - * - * @param name an Attribute name - * @param element a DOM node - */ - const _removeAttribute = function _removeAttribute(name, element) { - try { - arrayPush(DOMPurify.removed, { - attribute: element.getAttributeNode(name), - from: element - }); - } catch (_) { - arrayPush(DOMPurify.removed, { - attribute: null, - from: element - }); - } - element.removeAttribute(name); - // We void attribute values for unremovable "is" attributes - if (name === 'is') { - if (RETURN_DOM || RETURN_DOM_FRAGMENT) { - try { - _forceRemove(element); - } catch (_) {} - } else { - try { - element.setAttribute(name, ''); - } catch (_) {} - } - } - }; - /** - * _initDocument - * - * @param dirty - a string of dirty markup - * @return a DOM, filled with the dirty markup - */ - const _initDocument = function _initDocument(dirty) { - /* Create a HTML document */ - let doc = null; - let leadingWhitespace = null; - if (FORCE_BODY) { - dirty = '' + dirty; - } else { - /* If FORCE_BODY isn't used, leading whitespace needs to be preserved manually */ - const matches = stringMatch(dirty, /^[\r\n\t ]+/); - leadingWhitespace = matches && matches[0]; - } - if (PARSER_MEDIA_TYPE === 'application/xhtml+xml' && NAMESPACE === HTML_NAMESPACE) { - // Root of XHTML doc must contain xmlns declaration (see https://www.w3.org/TR/xhtml1/normative.html#strict) - dirty = '' + dirty + ''; - } - const dirtyPayload = trustedTypesPolicy ? trustedTypesPolicy.createHTML(dirty) : dirty; - /* - * Use the DOMParser API by default, fallback later if needs be - * DOMParser not work for svg when has multiple root element. - */ - if (NAMESPACE === HTML_NAMESPACE) { - try { - doc = new DOMParser().parseFromString(dirtyPayload, PARSER_MEDIA_TYPE); - } catch (_) {} - } - /* Use createHTMLDocument in case DOMParser is not available */ - if (!doc || !doc.documentElement) { - doc = implementation.createDocument(NAMESPACE, 'template', null); - try { - doc.documentElement.innerHTML = IS_EMPTY_INPUT ? emptyHTML : dirtyPayload; - } catch (_) { - // Syntax error if dirtyPayload is invalid xml - } - } - const body = doc.body || doc.documentElement; - if (dirty && leadingWhitespace) { - body.insertBefore(document.createTextNode(leadingWhitespace), body.childNodes[0] || null); - } - /* Work on whole document or just its body */ - if (NAMESPACE === HTML_NAMESPACE) { - return getElementsByTagName.call(doc, WHOLE_DOCUMENT ? 'html' : 'body')[0]; - } - return WHOLE_DOCUMENT ? doc.documentElement : body; - }; - /** - * Creates a NodeIterator object that you can use to traverse filtered lists of nodes or elements in a document. - * - * @param root The root element or node to start traversing on. - * @return The created NodeIterator - */ - const _createNodeIterator = function _createNodeIterator(root) { - return createNodeIterator.call(root.ownerDocument || root, root, - // eslint-disable-next-line no-bitwise - NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_COMMENT | NodeFilter.SHOW_TEXT | NodeFilter.SHOW_PROCESSING_INSTRUCTION | NodeFilter.SHOW_CDATA_SECTION, null); - }; - /** - * _isClobbered - * - * @param element element to check for clobbering attacks - * @return true if clobbered, false if safe - */ - const _isClobbered = function _isClobbered(element) { - return element instanceof HTMLFormElement && (typeof element.nodeName !== 'string' || typeof element.textContent !== 'string' || typeof element.removeChild !== 'function' || !(element.attributes instanceof NamedNodeMap) || typeof element.removeAttribute !== 'function' || typeof element.setAttribute !== 'function' || typeof element.namespaceURI !== 'string' || typeof element.insertBefore !== 'function' || typeof element.hasChildNodes !== 'function'); - }; - /** - * Checks whether the given object is a DOM node. - * - * @param value object to check whether it's a DOM node - * @return true is object is a DOM node - */ - const _isNode = function _isNode(value) { - return typeof Node === 'function' && value instanceof Node; - }; - function _executeHooks(hooks, currentNode, data) { - arrayForEach(hooks, hook => { - hook.call(DOMPurify, currentNode, data, CONFIG); - }); - } - /** - * _sanitizeElements - * - * @protect nodeName - * @protect textContent - * @protect removeChild - * @param currentNode to check for permission to exist - * @return true if node was killed, false if left alive - */ - const _sanitizeElements = function _sanitizeElements(currentNode) { - let content = null; - /* Execute a hook if present */ - _executeHooks(hooks.beforeSanitizeElements, currentNode, null); - /* Check if element is clobbered or can clobber */ - if (_isClobbered(currentNode)) { - _forceRemove(currentNode); - return true; - } - /* Now let's check the element's type and name */ - const tagName = transformCaseFunc(currentNode.nodeName); - /* Execute a hook if present */ - _executeHooks(hooks.uponSanitizeElement, currentNode, { - tagName, - allowedTags: ALLOWED_TAGS - }); - /* Detect mXSS attempts abusing namespace confusion */ - if (currentNode.hasChildNodes() && !_isNode(currentNode.firstElementChild) && regExpTest(/<[/\w!]/g, currentNode.innerHTML) && regExpTest(/<[/\w!]/g, currentNode.textContent)) { - _forceRemove(currentNode); - return true; - } - /* Remove any occurrence of processing instructions */ - if (currentNode.nodeType === NODE_TYPE.progressingInstruction) { - _forceRemove(currentNode); - return true; - } - /* Remove any kind of possibly harmful comments */ - if (SAFE_FOR_XML && currentNode.nodeType === NODE_TYPE.comment && regExpTest(/<[/\w]/g, currentNode.data)) { - _forceRemove(currentNode); - return true; - } - /* Remove element if anything forbids its presence */ - if (!ALLOWED_TAGS[tagName] || FORBID_TAGS[tagName]) { - /* Check if we have a custom element to handle */ - if (!FORBID_TAGS[tagName] && _isBasicCustomElement(tagName)) { - if (CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof RegExp && regExpTest(CUSTOM_ELEMENT_HANDLING.tagNameCheck, tagName)) { - return false; - } - if (CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof Function && CUSTOM_ELEMENT_HANDLING.tagNameCheck(tagName)) { - return false; - } - } - /* Keep content except for bad-listed elements */ - if (KEEP_CONTENT && !FORBID_CONTENTS[tagName]) { - const parentNode = getParentNode(currentNode) || currentNode.parentNode; - const childNodes = getChildNodes(currentNode) || currentNode.childNodes; - if (childNodes && parentNode) { - const childCount = childNodes.length; - for (let i = childCount - 1; i >= 0; --i) { - const childClone = cloneNode(childNodes[i], true); - childClone.__removalCount = (currentNode.__removalCount || 0) + 1; - parentNode.insertBefore(childClone, getNextSibling(currentNode)); - } - } - } - _forceRemove(currentNode); - return true; - } - /* Check whether element has a valid namespace */ - if (currentNode instanceof Element && !_checkValidNamespace(currentNode)) { - _forceRemove(currentNode); - return true; - } - /* Make sure that older browsers don't get fallback-tag mXSS */ - if ((tagName === 'noscript' || tagName === 'noembed' || tagName === 'noframes') && regExpTest(/<\/no(script|embed|frames)/i, currentNode.innerHTML)) { - _forceRemove(currentNode); - return true; - } - /* Sanitize element content to be template-safe */ - if (SAFE_FOR_TEMPLATES && currentNode.nodeType === NODE_TYPE.text) { - /* Get the element's text content */ - content = currentNode.textContent; - arrayForEach([MUSTACHE_EXPR, ERB_EXPR, TMPLIT_EXPR], expr => { - content = stringReplace(content, expr, ' '); - }); - if (currentNode.textContent !== content) { - arrayPush(DOMPurify.removed, { - element: currentNode.cloneNode() - }); - currentNode.textContent = content; - } - } - /* Execute a hook if present */ - _executeHooks(hooks.afterSanitizeElements, currentNode, null); - return false; - }; - /** - * _isValidAttribute - * - * @param lcTag Lowercase tag name of containing element. - * @param lcName Lowercase attribute name. - * @param value Attribute value. - * @return Returns true if `value` is valid, otherwise false. - */ - // eslint-disable-next-line complexity - const _isValidAttribute = function _isValidAttribute(lcTag, lcName, value) { - /* Make sure attribute cannot clobber */ - if (SANITIZE_DOM && (lcName === 'id' || lcName === 'name') && (value in document || value in formElement)) { - return false; - } - /* Allow valid data-* attributes: At least one character after "-" - (https://html.spec.whatwg.org/multipage/dom.html#embedding-custom-non-visible-data-with-the-data-*-attributes) - XML-compatible (https://html.spec.whatwg.org/multipage/infrastructure.html#xml-compatible and http://www.w3.org/TR/xml/#d0e804) - We don't need to check the value; it's always URI safe. */ - if (ALLOW_DATA_ATTR && !FORBID_ATTR[lcName] && regExpTest(DATA_ATTR, lcName)) ;else if (ALLOW_ARIA_ATTR && regExpTest(ARIA_ATTR, lcName)) ;else if (!ALLOWED_ATTR[lcName] || FORBID_ATTR[lcName]) { - if ( - // First condition does a very basic check if a) it's basically a valid custom element tagname AND - // b) if the tagName passes whatever the user has configured for CUSTOM_ELEMENT_HANDLING.tagNameCheck - // and c) if the attribute name passes whatever the user has configured for CUSTOM_ELEMENT_HANDLING.attributeNameCheck - _isBasicCustomElement(lcTag) && (CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof RegExp && regExpTest(CUSTOM_ELEMENT_HANDLING.tagNameCheck, lcTag) || CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof Function && CUSTOM_ELEMENT_HANDLING.tagNameCheck(lcTag)) && (CUSTOM_ELEMENT_HANDLING.attributeNameCheck instanceof RegExp && regExpTest(CUSTOM_ELEMENT_HANDLING.attributeNameCheck, lcName) || CUSTOM_ELEMENT_HANDLING.attributeNameCheck instanceof Function && CUSTOM_ELEMENT_HANDLING.attributeNameCheck(lcName)) || - // Alternative, second condition checks if it's an `is`-attribute, AND - // the value passes whatever the user has configured for CUSTOM_ELEMENT_HANDLING.tagNameCheck - lcName === 'is' && CUSTOM_ELEMENT_HANDLING.allowCustomizedBuiltInElements && (CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof RegExp && regExpTest(CUSTOM_ELEMENT_HANDLING.tagNameCheck, value) || CUSTOM_ELEMENT_HANDLING.tagNameCheck instanceof Function && CUSTOM_ELEMENT_HANDLING.tagNameCheck(value))) ;else { - return false; - } - /* Check value is safe. First, is attr inert? If so, is safe */ - } else if (URI_SAFE_ATTRIBUTES[lcName]) ;else if (regExpTest(IS_ALLOWED_URI$1, stringReplace(value, ATTR_WHITESPACE, ''))) ;else if ((lcName === 'src' || lcName === 'xlink:href' || lcName === 'href') && lcTag !== 'script' && stringIndexOf(value, 'data:') === 0 && DATA_URI_TAGS[lcTag]) ;else if (ALLOW_UNKNOWN_PROTOCOLS && !regExpTest(IS_SCRIPT_OR_DATA, stringReplace(value, ATTR_WHITESPACE, ''))) ;else if (value) { - return false; - } else ; - return true; - }; - /** - * _isBasicCustomElement - * checks if at least one dash is included in tagName, and it's not the first char - * for more sophisticated checking see https://github.com/sindresorhus/validate-element-name - * - * @param tagName name of the tag of the node to sanitize - * @returns Returns true if the tag name meets the basic criteria for a custom element, otherwise false. - */ - const _isBasicCustomElement = function _isBasicCustomElement(tagName) { - return tagName !== 'annotation-xml' && stringMatch(tagName, CUSTOM_ELEMENT); - }; - /** - * _sanitizeAttributes - * - * @protect attributes - * @protect nodeName - * @protect removeAttribute - * @protect setAttribute - * - * @param currentNode to sanitize - */ - const _sanitizeAttributes = function _sanitizeAttributes(currentNode) { - /* Execute a hook if present */ - _executeHooks(hooks.beforeSanitizeAttributes, currentNode, null); - const { - attributes - } = currentNode; - /* Check if we have attributes; if not we might have a text node */ - if (!attributes || _isClobbered(currentNode)) { - return; - } - const hookEvent = { - attrName: '', - attrValue: '', - keepAttr: true, - allowedAttributes: ALLOWED_ATTR, - forceKeepAttr: undefined - }; - let l = attributes.length; - /* Go backwards over all attributes; safely remove bad ones */ - while (l--) { - const attr = attributes[l]; - const { - name, - namespaceURI, - value: attrValue - } = attr; - const lcName = transformCaseFunc(name); - let value = name === 'value' ? attrValue : stringTrim(attrValue); - /* Execute a hook if present */ - hookEvent.attrName = lcName; - hookEvent.attrValue = value; - hookEvent.keepAttr = true; - hookEvent.forceKeepAttr = undefined; // Allows developers to see this is a property they can set - _executeHooks(hooks.uponSanitizeAttribute, currentNode, hookEvent); - value = hookEvent.attrValue; - /* Full DOM Clobbering protection via namespace isolation, - * Prefix id and name attributes with `user-content-` - */ - if (SANITIZE_NAMED_PROPS && (lcName === 'id' || lcName === 'name')) { - // Remove the attribute with this value - _removeAttribute(name, currentNode); - // Prefix the value and later re-create the attribute with the sanitized value - value = SANITIZE_NAMED_PROPS_PREFIX + value; - } - /* Work around a security issue with comments inside attributes */ - if (SAFE_FOR_XML && regExpTest(/((--!?|])>)|<\/(style|title)/i, value)) { - _removeAttribute(name, currentNode); - continue; - } - /* Did the hooks approve of the attribute? */ - if (hookEvent.forceKeepAttr) { - continue; - } - /* Remove attribute */ - _removeAttribute(name, currentNode); - /* Did the hooks approve of the attribute? */ - if (!hookEvent.keepAttr) { - continue; - } - /* Work around a security issue in jQuery 3.0 */ - if (!ALLOW_SELF_CLOSE_IN_ATTR && regExpTest(/\/>/i, value)) { - _removeAttribute(name, currentNode); - continue; - } - /* Sanitize attribute content to be template-safe */ - if (SAFE_FOR_TEMPLATES) { - arrayForEach([MUSTACHE_EXPR, ERB_EXPR, TMPLIT_EXPR], expr => { - value = stringReplace(value, expr, ' '); - }); - } - /* Is `value` valid for this attribute? */ - const lcTag = transformCaseFunc(currentNode.nodeName); - if (!_isValidAttribute(lcTag, lcName, value)) { - continue; - } - /* Handle attributes that require Trusted Types */ - if (trustedTypesPolicy && typeof trustedTypes === 'object' && typeof trustedTypes.getAttributeType === 'function') { - if (namespaceURI) ;else { - switch (trustedTypes.getAttributeType(lcTag, lcName)) { - case 'TrustedHTML': - { - value = trustedTypesPolicy.createHTML(value); - break; - } - case 'TrustedScriptURL': - { - value = trustedTypesPolicy.createScriptURL(value); - break; - } - } - } - } - /* Handle invalid data-* attribute set by try-catching it */ - try { - if (namespaceURI) { - currentNode.setAttributeNS(namespaceURI, name, value); - } else { - /* Fallback to setAttribute() for browser-unrecognized namespaces e.g. "x-schema". */ - currentNode.setAttribute(name, value); - } - if (_isClobbered(currentNode)) { - _forceRemove(currentNode); - } else { - arrayPop(DOMPurify.removed); - } - } catch (_) {} - } - /* Execute a hook if present */ - _executeHooks(hooks.afterSanitizeAttributes, currentNode, null); - }; - /** - * _sanitizeShadowDOM - * - * @param fragment to iterate over recursively - */ - const _sanitizeShadowDOM = function _sanitizeShadowDOM(fragment) { - let shadowNode = null; - const shadowIterator = _createNodeIterator(fragment); - /* Execute a hook if present */ - _executeHooks(hooks.beforeSanitizeShadowDOM, fragment, null); - while (shadowNode = shadowIterator.nextNode()) { - /* Execute a hook if present */ - _executeHooks(hooks.uponSanitizeShadowNode, shadowNode, null); - /* Sanitize tags and elements */ - _sanitizeElements(shadowNode); - /* Check attributes next */ - _sanitizeAttributes(shadowNode); - /* Deep shadow DOM detected */ - if (shadowNode.content instanceof DocumentFragment) { - _sanitizeShadowDOM(shadowNode.content); - } - } - /* Execute a hook if present */ - _executeHooks(hooks.afterSanitizeShadowDOM, fragment, null); - }; - // eslint-disable-next-line complexity - DOMPurify.sanitize = function (dirty) { - let cfg = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; - let body = null; - let importedNode = null; - let currentNode = null; - let returnNode = null; - /* Make sure we have a string to sanitize. - DO NOT return early, as this will return the wrong type if - the user has requested a DOM object rather than a string */ - IS_EMPTY_INPUT = !dirty; - if (IS_EMPTY_INPUT) { - dirty = ''; - } - /* Stringify, in case dirty is an object */ - if (typeof dirty !== 'string' && !_isNode(dirty)) { - if (typeof dirty.toString === 'function') { - dirty = dirty.toString(); - if (typeof dirty !== 'string') { - throw typeErrorCreate('dirty is not a string, aborting'); - } - } else { - throw typeErrorCreate('toString is not a function'); - } - } - /* Return dirty HTML if DOMPurify cannot run */ - if (!DOMPurify.isSupported) { - return dirty; - } - /* Assign config vars */ - if (!SET_CONFIG) { - _parseConfig(cfg); - } - /* Clean up removed elements */ - DOMPurify.removed = []; - /* Check if dirty is correctly typed for IN_PLACE */ - if (typeof dirty === 'string') { - IN_PLACE = false; - } - if (IN_PLACE) { - /* Do some early pre-sanitization to avoid unsafe root nodes */ - if (dirty.nodeName) { - const tagName = transformCaseFunc(dirty.nodeName); - if (!ALLOWED_TAGS[tagName] || FORBID_TAGS[tagName]) { - throw typeErrorCreate('root node is forbidden and cannot be sanitized in-place'); - } - } - } else if (dirty instanceof Node) { - /* If dirty is a DOM element, append to an empty document to avoid - elements being stripped by the parser */ - body = _initDocument(''); - importedNode = body.ownerDocument.importNode(dirty, true); - if (importedNode.nodeType === NODE_TYPE.element && importedNode.nodeName === 'BODY') { - /* Node is already a body, use as is */ - body = importedNode; - } else if (importedNode.nodeName === 'HTML') { - body = importedNode; - } else { - // eslint-disable-next-line unicorn/prefer-dom-node-append - body.appendChild(importedNode); - } - } else { - /* Exit directly if we have nothing to do */ - if (!RETURN_DOM && !SAFE_FOR_TEMPLATES && !WHOLE_DOCUMENT && - // eslint-disable-next-line unicorn/prefer-includes - dirty.indexOf('<') === -1) { - return trustedTypesPolicy && RETURN_TRUSTED_TYPE ? trustedTypesPolicy.createHTML(dirty) : dirty; - } - /* Initialize the document to work on */ - body = _initDocument(dirty); - /* Check we have a DOM node from the data */ - if (!body) { - return RETURN_DOM ? null : RETURN_TRUSTED_TYPE ? emptyHTML : ''; - } - } - /* Remove first element node (ours) if FORCE_BODY is set */ - if (body && FORCE_BODY) { - _forceRemove(body.firstChild); - } - /* Get node iterator */ - const nodeIterator = _createNodeIterator(IN_PLACE ? dirty : body); - /* Now start iterating over the created document */ - while (currentNode = nodeIterator.nextNode()) { - /* Sanitize tags and elements */ - _sanitizeElements(currentNode); - /* Check attributes next */ - _sanitizeAttributes(currentNode); - /* Shadow DOM detected, sanitize it */ - if (currentNode.content instanceof DocumentFragment) { - _sanitizeShadowDOM(currentNode.content); - } - } - /* If we sanitized `dirty` in-place, return it. */ - if (IN_PLACE) { - return dirty; - } - /* Return sanitized string or DOM */ - if (RETURN_DOM) { - if (RETURN_DOM_FRAGMENT) { - returnNode = createDocumentFragment.call(body.ownerDocument); - while (body.firstChild) { - // eslint-disable-next-line unicorn/prefer-dom-node-append - returnNode.appendChild(body.firstChild); - } - } else { - returnNode = body; - } - if (ALLOWED_ATTR.shadowroot || ALLOWED_ATTR.shadowrootmode) { - /* - AdoptNode() is not used because internal state is not reset - (e.g. the past names map of a HTMLFormElement), this is safe - in theory but we would rather not risk another attack vector. - The state that is cloned by importNode() is explicitly defined - by the specs. - */ - returnNode = importNode.call(originalDocument, returnNode, true); - } - return returnNode; - } - let serializedHTML = WHOLE_DOCUMENT ? body.outerHTML : body.innerHTML; - /* Serialize doctype if allowed */ - if (WHOLE_DOCUMENT && ALLOWED_TAGS['!doctype'] && body.ownerDocument && body.ownerDocument.doctype && body.ownerDocument.doctype.name && regExpTest(DOCTYPE_NAME, body.ownerDocument.doctype.name)) { - serializedHTML = '\n' + serializedHTML; - } - /* Sanitize final string template-safe */ - if (SAFE_FOR_TEMPLATES) { - arrayForEach([MUSTACHE_EXPR, ERB_EXPR, TMPLIT_EXPR], expr => { - serializedHTML = stringReplace(serializedHTML, expr, ' '); - }); - } - return trustedTypesPolicy && RETURN_TRUSTED_TYPE ? trustedTypesPolicy.createHTML(serializedHTML) : serializedHTML; - }; - DOMPurify.setConfig = function () { - let cfg = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; - _parseConfig(cfg); - SET_CONFIG = true; - }; - DOMPurify.clearConfig = function () { - CONFIG = null; - SET_CONFIG = false; - }; - DOMPurify.isValidAttribute = function (tag, attr, value) { - /* Initialize shared config vars if necessary. */ - if (!CONFIG) { - _parseConfig({}); - } - const lcTag = transformCaseFunc(tag); - const lcName = transformCaseFunc(attr); - return _isValidAttribute(lcTag, lcName, value); - }; - DOMPurify.addHook = function (entryPoint, hookFunction) { - if (typeof hookFunction !== 'function') { - return; - } - arrayPush(hooks[entryPoint], hookFunction); - }; - DOMPurify.removeHook = function (entryPoint, hookFunction) { - if (hookFunction !== undefined) { - const index = arrayLastIndexOf(hooks[entryPoint], hookFunction); - return index === -1 ? undefined : arraySplice(hooks[entryPoint], index, 1)[0]; - } - return arrayPop(hooks[entryPoint]); - }; - DOMPurify.removeHooks = function (entryPoint) { - hooks[entryPoint] = []; - }; - DOMPurify.removeAllHooks = function () { - hooks = _createHooksMap(); - }; - return DOMPurify; - } - var purify = createDOMPurify(); - - purify.addHook("uponSanitizeAttribute", function (node, data) { - const allowedAttributePattern = /^data-trix-/; - if (allowedAttributePattern.test(data.attrName)) { - data.forceKeepAttr = true; - } - }); - const DEFAULT_ALLOWED_ATTRIBUTES = "style href src width height language class".split(" "); - const DEFAULT_FORBIDDEN_PROTOCOLS = "javascript:".split(" "); - const DEFAULT_FORBIDDEN_ELEMENTS = "script iframe form noscript".split(" "); - class HTMLSanitizer extends BasicObject { - static setHTML(element, html, options) { - const sanitizedElement = new this(html, options).sanitize(); - const sanitizedHtml = sanitizedElement.getHTML ? sanitizedElement.getHTML() : sanitizedElement.outerHTML; - element.innerHTML = sanitizedHtml; - } - static sanitize(html, options) { - const sanitizer = new this(html, options); - sanitizer.sanitize(); - return sanitizer; - } - constructor(html) { - let { - allowedAttributes, - forbiddenProtocols, - forbiddenElements, - purifyOptions - } = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; - super(...arguments); - this.allowedAttributes = allowedAttributes || DEFAULT_ALLOWED_ATTRIBUTES; - this.forbiddenProtocols = forbiddenProtocols || DEFAULT_FORBIDDEN_PROTOCOLS; - this.forbiddenElements = forbiddenElements || DEFAULT_FORBIDDEN_ELEMENTS; - this.purifyOptions = purifyOptions || {}; - this.body = createBodyElementForHTML(html); - } - sanitize() { - this.sanitizeElements(); - this.normalizeListElementNesting(); - const purifyConfig = Object.assign({}, dompurify, this.purifyOptions); - purify.setConfig(purifyConfig); - this.body = purify.sanitize(this.body); - return this.body; - } - getHTML() { - return this.body.innerHTML; - } - getBody() { - return this.body; - } - - // Private - - sanitizeElements() { - const walker = walkTree(this.body); - const nodesToRemove = []; - while (walker.nextNode()) { - const node = walker.currentNode; - switch (node.nodeType) { - case Node.ELEMENT_NODE: - if (this.elementIsRemovable(node)) { - nodesToRemove.push(node); - } else { - this.sanitizeElement(node); - } - break; - case Node.COMMENT_NODE: - nodesToRemove.push(node); - break; - } - } - nodesToRemove.forEach(node => removeNode(node)); - return this.body; - } - sanitizeElement(element) { - if (element.hasAttribute("href")) { - if (this.forbiddenProtocols.includes(element.protocol)) { - element.removeAttribute("href"); - } - } - Array.from(element.attributes).forEach(_ref => { - let { - name - } = _ref; - if (!this.allowedAttributes.includes(name) && name.indexOf("data-trix") !== 0) { - element.removeAttribute(name); - } - }); - return element; - } - normalizeListElementNesting() { - Array.from(this.body.querySelectorAll("ul,ol")).forEach(listElement => { - const previousElement = listElement.previousElementSibling; - if (previousElement) { - if (tagName(previousElement) === "li") { - previousElement.appendChild(listElement); - } - } - }); - return this.body; - } - elementIsRemovable(element) { - if ((element === null || element === void 0 ? void 0 : element.nodeType) !== Node.ELEMENT_NODE) return; - return this.elementIsForbidden(element) || this.elementIsntSerializable(element); - } - elementIsForbidden(element) { - return this.forbiddenElements.includes(tagName(element)); - } - elementIsntSerializable(element) { - return element.getAttribute("data-trix-serialize") === "false" && !nodeIsAttachmentElement(element); - } - } - const createBodyElementForHTML = function () { - let html = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : ""; - // Remove everything after - html = html.replace(/<\/html[^>]*>[^]*$/i, ""); - const doc = document.implementation.createHTMLDocument(""); - doc.documentElement.innerHTML = html; - Array.from(doc.head.querySelectorAll("style")).forEach(element => { - doc.body.appendChild(element); - }); - return doc.body; - }; - - const { - css: css$2 - } = config; - class AttachmentView extends ObjectView { - constructor() { - super(...arguments); - this.attachment = this.object; - this.attachment.uploadProgressDelegate = this; - this.attachmentPiece = this.options.piece; - } - createContentNodes() { - return []; - } - createNodes() { - let innerElement; - const figure = innerElement = makeElement({ - tagName: "figure", - className: this.getClassName(), - data: this.getData(), - editable: false - }); - const href = this.getHref(); - if (href) { - innerElement = makeElement({ - tagName: "a", - editable: false, - attributes: { - href, - tabindex: -1 - } - }); - figure.appendChild(innerElement); - } - if (this.attachment.hasContent()) { - HTMLSanitizer.setHTML(innerElement, this.attachment.getContent()); - } else { - this.createContentNodes().forEach(node => { - innerElement.appendChild(node); - }); - } - innerElement.appendChild(this.createCaptionElement()); - if (this.attachment.isPending()) { - this.progressElement = makeElement({ - tagName: "progress", - attributes: { - class: css$2.attachmentProgress, - value: this.attachment.getUploadProgress(), - max: 100 - }, - data: { - trixMutable: true, - trixStoreKey: ["progressElement", this.attachment.id].join("/") - } - }); - figure.appendChild(this.progressElement); - } - return [createCursorTarget("left"), figure, createCursorTarget("right")]; - } - createCaptionElement() { - const figcaption = makeElement({ - tagName: "figcaption", - className: css$2.attachmentCaption - }); - const caption = this.attachmentPiece.getCaption(); - if (caption) { - figcaption.classList.add("".concat(css$2.attachmentCaption, "--edited")); - figcaption.textContent = caption; - } else { - let name, size; - const captionConfig = this.getCaptionConfig(); - if (captionConfig.name) { - name = this.attachment.getFilename(); - } - if (captionConfig.size) { - size = this.attachment.getFormattedFilesize(); - } - if (name) { - const nameElement = makeElement({ - tagName: "span", - className: css$2.attachmentName, - textContent: name - }); - figcaption.appendChild(nameElement); - } - if (size) { - if (name) { - figcaption.appendChild(document.createTextNode(" ")); - } - const sizeElement = makeElement({ - tagName: "span", - className: css$2.attachmentSize, - textContent: size - }); - figcaption.appendChild(sizeElement); - } - } - return figcaption; - } - getClassName() { - const names = [css$2.attachment, "".concat(css$2.attachment, "--").concat(this.attachment.getType())]; - const extension = this.attachment.getExtension(); - if (extension) { - names.push("".concat(css$2.attachment, "--").concat(extension)); - } - return names.join(" "); - } - getData() { - const data = { - trixAttachment: JSON.stringify(this.attachment), - trixContentType: this.attachment.getContentType(), - trixId: this.attachment.id - }; - const { - attributes - } = this.attachmentPiece; - if (!attributes.isEmpty()) { - data.trixAttributes = JSON.stringify(attributes); - } - if (this.attachment.isPending()) { - data.trixSerialize = false; - } - return data; - } - getHref() { - if (!htmlContainsTagName(this.attachment.getContent(), "a")) { - return this.attachment.getHref(); - } - } - getCaptionConfig() { - var _config$attachments$t; - const type = this.attachment.getType(); - const captionConfig = copyObject((_config$attachments$t = attachments[type]) === null || _config$attachments$t === void 0 ? void 0 : _config$attachments$t.caption); - if (type === "file") { - captionConfig.name = true; - } - return captionConfig; - } - findProgressElement() { - var _this$findElement; - return (_this$findElement = this.findElement()) === null || _this$findElement === void 0 ? void 0 : _this$findElement.querySelector("progress"); - } - - // Attachment delegate - - attachmentDidChangeUploadProgress() { - const value = this.attachment.getUploadProgress(); - const progressElement = this.findProgressElement(); - if (progressElement) { - progressElement.value = value; - } - } - } - const createCursorTarget = name => makeElement({ - tagName: "span", - textContent: ZERO_WIDTH_SPACE, - data: { - trixCursorTarget: name, - trixSerialize: false - } - }); - const htmlContainsTagName = function (html, tagName) { - const div = makeElement("div"); - HTMLSanitizer.setHTML(div, html || ""); - return div.querySelector(tagName); - }; - - class PreviewableAttachmentView extends AttachmentView { - constructor() { - super(...arguments); - this.attachment.previewDelegate = this; - } - createContentNodes() { - this.image = makeElement({ - tagName: "img", - attributes: { - src: "" - }, - data: { - trixMutable: true - } - }); - this.refresh(this.image); - return [this.image]; - } - createCaptionElement() { - const figcaption = super.createCaptionElement(...arguments); - if (!figcaption.textContent) { - figcaption.setAttribute("data-trix-placeholder", lang$1.captionPlaceholder); - } - return figcaption; - } - refresh(image) { - if (!image) { - var _this$findElement; - image = (_this$findElement = this.findElement()) === null || _this$findElement === void 0 ? void 0 : _this$findElement.querySelector("img"); - } - if (image) { - return this.updateAttributesForImage(image); - } - } - updateAttributesForImage(image) { - const url = this.attachment.getURL(); - const previewURL = this.attachment.getPreviewURL(); - image.src = previewURL || url; - if (previewURL === url) { - image.removeAttribute("data-trix-serialized-attributes"); - } else { - const serializedAttributes = JSON.stringify({ - src: url - }); - image.setAttribute("data-trix-serialized-attributes", serializedAttributes); - } - const width = this.attachment.getWidth(); - const height = this.attachment.getHeight(); - if (width != null) { - image.width = width; - } - if (height != null) { - image.height = height; - } - const storeKey = ["imageElement", this.attachment.id, image.src, image.width, image.height].join("/"); - image.dataset.trixStoreKey = storeKey; - } - - // Attachment delegate - - attachmentDidChangeAttributes() { - this.refresh(this.image); - return this.refresh(); - } - } - - /* eslint-disable - no-useless-escape, - no-var, - */ - class PieceView extends ObjectView { - constructor() { - super(...arguments); - this.piece = this.object; - this.attributes = this.piece.getAttributes(); - this.textConfig = this.options.textConfig; - this.context = this.options.context; - if (this.piece.attachment) { - this.attachment = this.piece.attachment; - } else { - this.string = this.piece.toString(); - } - } - createNodes() { - let nodes = this.attachment ? this.createAttachmentNodes() : this.createStringNodes(); - const element = this.createElement(); - if (element) { - const innerElement = findInnerElement(element); - Array.from(nodes).forEach(node => { - innerElement.appendChild(node); - }); - nodes = [element]; - } - return nodes; - } - createAttachmentNodes() { - const constructor = this.attachment.isPreviewable() ? PreviewableAttachmentView : AttachmentView; - const view = this.createChildView(constructor, this.piece.attachment, { - piece: this.piece - }); - return view.getNodes(); - } - createStringNodes() { - var _this$textConfig; - if ((_this$textConfig = this.textConfig) !== null && _this$textConfig !== void 0 && _this$textConfig.plaintext) { - return [document.createTextNode(this.string)]; - } else { - const nodes = []; - const iterable = this.string.split("\n"); - for (let index = 0; index < iterable.length; index++) { - const substring = iterable[index]; - if (index > 0) { - const element = makeElement("br"); - nodes.push(element); - } - if (substring.length) { - const node = document.createTextNode(this.preserveSpaces(substring)); - nodes.push(node); - } - } - return nodes; - } - } - createElement() { - let element, key, value; - const styles = {}; - for (key in this.attributes) { - value = this.attributes[key]; - const config = getTextConfig(key); - if (config) { - if (config.tagName) { - var innerElement; - const pendingElement = makeElement(config.tagName); - if (innerElement) { - innerElement.appendChild(pendingElement); - innerElement = pendingElement; - } else { - element = innerElement = pendingElement; - } - } - if (config.styleProperty) { - styles[config.styleProperty] = value; - } - if (config.style) { - for (key in config.style) { - value = config.style[key]; - styles[key] = value; - } - } - } - } - if (Object.keys(styles).length) { - if (!element) { - element = makeElement("span"); - } - for (key in styles) { - value = styles[key]; - element.style[key] = value; - } - } - return element; - } - createContainerElement() { - for (const key in this.attributes) { - const value = this.attributes[key]; - const config = getTextConfig(key); - if (config) { - if (config.groupTagName) { - const attributes = {}; - attributes[key] = value; - return makeElement(config.groupTagName, attributes); - } - } - } - } - preserveSpaces(string) { - if (this.context.isLast) { - string = string.replace(/\ $/, NON_BREAKING_SPACE); - } - string = string.replace(/(\S)\ {3}(\S)/g, "$1 ".concat(NON_BREAKING_SPACE, " $2")).replace(/\ {2}/g, "".concat(NON_BREAKING_SPACE, " ")).replace(/\ {2}/g, " ".concat(NON_BREAKING_SPACE)); - if (this.context.isFirst || this.context.followsWhitespace) { - string = string.replace(/^\ /, NON_BREAKING_SPACE); - } - return string; - } - } - - /* eslint-disable - no-var, - */ - class TextView extends ObjectView { - constructor() { - super(...arguments); - this.text = this.object; - this.textConfig = this.options.textConfig; - } - createNodes() { - const nodes = []; - const pieces = ObjectGroup.groupObjects(this.getPieces()); - const lastIndex = pieces.length - 1; - for (let index = 0; index < pieces.length; index++) { - const piece = pieces[index]; - const context = {}; - if (index === 0) { - context.isFirst = true; - } - if (index === lastIndex) { - context.isLast = true; - } - if (endsWithWhitespace(previousPiece)) { - context.followsWhitespace = true; - } - const view = this.findOrCreateCachedChildView(PieceView, piece, { - textConfig: this.textConfig, - context - }); - nodes.push(...Array.from(view.getNodes() || [])); - var previousPiece = piece; - } - return nodes; - } - getPieces() { - return Array.from(this.text.getPieces()).filter(piece => !piece.hasAttribute("blockBreak")); - } - } - const endsWithWhitespace = piece => /\s$/.test(piece === null || piece === void 0 ? void 0 : piece.toString()); - - const { - css: css$1 - } = config; - class BlockView extends ObjectView { - constructor() { - super(...arguments); - this.block = this.object; - this.attributes = this.block.getAttributes(); - } - createNodes() { - const comment = document.createComment("block"); - const nodes = [comment]; - if (this.block.isEmpty()) { - nodes.push(makeElement("br")); - } else { - var _getBlockConfig; - const textConfig = (_getBlockConfig = getBlockConfig(this.block.getLastAttribute())) === null || _getBlockConfig === void 0 ? void 0 : _getBlockConfig.text; - const textView = this.findOrCreateCachedChildView(TextView, this.block.text, { - textConfig - }); - nodes.push(...Array.from(textView.getNodes() || [])); - if (this.shouldAddExtraNewlineElement()) { - nodes.push(makeElement("br")); - } - } - if (this.attributes.length) { - return nodes; - } else { - let attributes$1; - const { - tagName - } = attributes.default; - if (this.block.isRTL()) { - attributes$1 = { - dir: "rtl" - }; - } - const element = makeElement({ - tagName, - attributes: attributes$1 - }); - nodes.forEach(node => element.appendChild(node)); - return [element]; - } - } - createContainerElement(depth) { - const attributes = {}; - let className; - const attributeName = this.attributes[depth]; - const { - tagName, - htmlAttributes = [] - } = getBlockConfig(attributeName); - if (depth === 0 && this.block.isRTL()) { - Object.assign(attributes, { - dir: "rtl" - }); - } - if (attributeName === "attachmentGallery") { - const size = this.block.getBlockBreakPosition(); - className = "".concat(css$1.attachmentGallery, " ").concat(css$1.attachmentGallery, "--").concat(size); - } - Object.entries(this.block.htmlAttributes).forEach(_ref => { - let [name, value] = _ref; - if (htmlAttributes.includes(name)) { - attributes[name] = value; - } - }); - return makeElement({ - tagName, - className, - attributes - }); - } - - // A single
at the end of a block element has no visual representation - // so add an extra one. - shouldAddExtraNewlineElement() { - return /\n\n$/.test(this.block.toString()); - } - } - - class DocumentView extends ObjectView { - static render(document) { - const element = makeElement("div"); - const view = new this(document, { - element - }); - view.render(); - view.sync(); - return element; - } - constructor() { - super(...arguments); - this.element = this.options.element; - this.elementStore = new ElementStore(); - this.setDocument(this.object); - } - setDocument(document) { - if (!document.isEqualTo(this.document)) { - this.document = this.object = document; - } - } - render() { - this.childViews = []; - this.shadowElement = makeElement("div"); - if (!this.document.isEmpty()) { - const objects = ObjectGroup.groupObjects(this.document.getBlocks(), { - asTree: true - }); - Array.from(objects).forEach(object => { - const view = this.findOrCreateCachedChildView(BlockView, object); - Array.from(view.getNodes()).map(node => this.shadowElement.appendChild(node)); - }); - } - } - isSynced() { - return elementsHaveEqualHTML(this.shadowElement, this.element); - } - sync() { - const fragment = this.createDocumentFragmentForSync(); - while (this.element.lastChild) { - this.element.removeChild(this.element.lastChild); - } - this.element.appendChild(fragment); - return this.didSync(); - } - - // Private - - didSync() { - this.elementStore.reset(findStoredElements(this.element)); - return defer(() => this.garbageCollectCachedViews()); - } - createDocumentFragmentForSync() { - const fragment = document.createDocumentFragment(); - Array.from(this.shadowElement.childNodes).forEach(node => { - fragment.appendChild(node.cloneNode(true)); - }); - Array.from(findStoredElements(fragment)).forEach(element => { - const storedElement = this.elementStore.remove(element); - if (storedElement) { - element.parentNode.replaceChild(storedElement, element); - } - }); - return fragment; - } - } - const findStoredElements = element => element.querySelectorAll("[data-trix-store-key]"); - const elementsHaveEqualHTML = (element, otherElement) => ignoreSpaces(element.innerHTML) === ignoreSpaces(otherElement.innerHTML); - const ignoreSpaces = html => html.replace(/ /g, " "); - - function _AsyncGenerator(e) { - var r, t; - function resume(r, t) { - try { - var n = e[r](t), - o = n.value, - u = o instanceof _OverloadYield; - Promise.resolve(u ? o.v : o).then(function (t) { - if (u) { - var i = "return" === r ? "return" : "next"; - if (!o.k || t.done) return resume(i, t); - t = e[i](t).value; - } - settle(n.done ? "return" : "normal", t); - }, function (e) { - resume("throw", e); - }); - } catch (e) { - settle("throw", e); - } - } - function settle(e, n) { - switch (e) { - case "return": - r.resolve({ - value: n, - done: !0 - }); - break; - case "throw": - r.reject(n); - break; - default: - r.resolve({ - value: n, - done: !1 - }); - } - (r = r.next) ? resume(r.key, r.arg) : t = null; - } - this._invoke = function (e, n) { - return new Promise(function (o, u) { - var i = { - key: e, - arg: n, - resolve: o, - reject: u, - next: null - }; - t ? t = t.next = i : (r = t = i, resume(e, n)); - }); - }, "function" != typeof e.return && (this.return = void 0); - } - _AsyncGenerator.prototype["function" == typeof Symbol && Symbol.asyncIterator || "@@asyncIterator"] = function () { - return this; - }, _AsyncGenerator.prototype.next = function (e) { - return this._invoke("next", e); - }, _AsyncGenerator.prototype.throw = function (e) { - return this._invoke("throw", e); - }, _AsyncGenerator.prototype.return = function (e) { - return this._invoke("return", e); - }; - function _OverloadYield(t, e) { - this.v = t, this.k = e; - } - function old_createMetadataMethodsForProperty(e, t, a, r) { - return { - getMetadata: function (o) { - old_assertNotFinished(r, "getMetadata"), old_assertMetadataKey(o); - var i = e[o]; - if (void 0 !== i) if (1 === t) { - var n = i.public; - if (void 0 !== n) return n[a]; - } else if (2 === t) { - var l = i.private; - if (void 0 !== l) return l.get(a); - } else if (Object.hasOwnProperty.call(i, "constructor")) return i.constructor; - }, - setMetadata: function (o, i) { - old_assertNotFinished(r, "setMetadata"), old_assertMetadataKey(o); - var n = e[o]; - if (void 0 === n && (n = e[o] = {}), 1 === t) { - var l = n.public; - void 0 === l && (l = n.public = {}), l[a] = i; - } else if (2 === t) { - var s = n.priv; - void 0 === s && (s = n.private = new Map()), s.set(a, i); - } else n.constructor = i; - } - }; - } - function old_convertMetadataMapToFinal(e, t) { - var a = e[Symbol.metadata || Symbol.for("Symbol.metadata")], - r = Object.getOwnPropertySymbols(t); - if (0 !== r.length) { - for (var o = 0; o < r.length; o++) { - var i = r[o], - n = t[i], - l = a ? a[i] : null, - s = n.public, - c = l ? l.public : null; - s && c && Object.setPrototypeOf(s, c); - var d = n.private; - if (d) { - var u = Array.from(d.values()), - f = l ? l.private : null; - f && (u = u.concat(f)), n.private = u; - } - l && Object.setPrototypeOf(n, l); - } - a && Object.setPrototypeOf(t, a), e[Symbol.metadata || Symbol.for("Symbol.metadata")] = t; - } - } - function old_createAddInitializerMethod(e, t) { - return function (a) { - old_assertNotFinished(t, "addInitializer"), old_assertCallable(a, "An initializer"), e.push(a); - }; - } - function old_memberDec(e, t, a, r, o, i, n, l, s) { - var c; - switch (i) { - case 1: - c = "accessor"; - break; - case 2: - c = "method"; - break; - case 3: - c = "getter"; - break; - case 4: - c = "setter"; - break; - default: - c = "field"; - } - var d, - u, - f = { - kind: c, - name: l ? "#" + t : t, - isStatic: n, - isPrivate: l - }, - p = { - v: !1 - }; - if (0 !== i && (f.addInitializer = old_createAddInitializerMethod(o, p)), l) { - d = 2, u = Symbol(t); - var v = {}; - 0 === i ? (v.get = a.get, v.set = a.set) : 2 === i ? v.get = function () { - return a.value; - } : (1 !== i && 3 !== i || (v.get = function () { - return a.get.call(this); - }), 1 !== i && 4 !== i || (v.set = function (e) { - a.set.call(this, e); - })), f.access = v; - } else d = 1, u = t; - try { - return e(s, Object.assign(f, old_createMetadataMethodsForProperty(r, d, u, p))); - } finally { - p.v = !0; - } - } - function old_assertNotFinished(e, t) { - if (e.v) throw new Error("attempted to call " + t + " after decoration was finished"); - } - function old_assertMetadataKey(e) { - if ("symbol" != typeof e) throw new TypeError("Metadata keys must be symbols, received: " + e); - } - function old_assertCallable(e, t) { - if ("function" != typeof e) throw new TypeError(t + " must be a function"); - } - function old_assertValidReturnValue(e, t) { - var a = typeof t; - if (1 === e) { - if ("object" !== a || null === t) throw new TypeError("accessor decorators must return an object with get, set, or init properties or void 0"); - void 0 !== t.get && old_assertCallable(t.get, "accessor.get"), void 0 !== t.set && old_assertCallable(t.set, "accessor.set"), void 0 !== t.init && old_assertCallable(t.init, "accessor.init"), void 0 !== t.initializer && old_assertCallable(t.initializer, "accessor.initializer"); - } else if ("function" !== a) { - var r; - throw r = 0 === e ? "field" : 10 === e ? "class" : "method", new TypeError(r + " decorators must return a function or void 0"); - } - } - function old_getInit(e) { - var t; - return null == (t = e.init) && (t = e.initializer) && "undefined" != typeof console && console.warn(".initializer has been renamed to .init as of March 2022"), t; - } - function old_applyMemberDec(e, t, a, r, o, i, n, l, s) { - var c, - d, - u, - f, - p, - v, - h = a[0]; - if (n ? c = 0 === o || 1 === o ? { - get: a[3], - set: a[4] - } : 3 === o ? { - get: a[3] - } : 4 === o ? { - set: a[3] - } : { - value: a[3] - } : 0 !== o && (c = Object.getOwnPropertyDescriptor(t, r)), 1 === o ? u = { - get: c.get, - set: c.set - } : 2 === o ? u = c.value : 3 === o ? u = c.get : 4 === o && (u = c.set), "function" == typeof h) void 0 !== (f = old_memberDec(h, r, c, l, s, o, i, n, u)) && (old_assertValidReturnValue(o, f), 0 === o ? d = f : 1 === o ? (d = old_getInit(f), p = f.get || u.get, v = f.set || u.set, u = { - get: p, - set: v - }) : u = f);else for (var y = h.length - 1; y >= 0; y--) { - var b; - if (void 0 !== (f = old_memberDec(h[y], r, c, l, s, o, i, n, u))) old_assertValidReturnValue(o, f), 0 === o ? b = f : 1 === o ? (b = old_getInit(f), p = f.get || u.get, v = f.set || u.set, u = { - get: p, - set: v - }) : u = f, void 0 !== b && (void 0 === d ? d = b : "function" == typeof d ? d = [d, b] : d.push(b)); - } - if (0 === o || 1 === o) { - if (void 0 === d) d = function (e, t) { - return t; - };else if ("function" != typeof d) { - var g = d; - d = function (e, t) { - for (var a = t, r = 0; r < g.length; r++) a = g[r].call(e, a); - return a; - }; - } else { - var m = d; - d = function (e, t) { - return m.call(e, t); - }; - } - e.push(d); - } - 0 !== o && (1 === o ? (c.get = u.get, c.set = u.set) : 2 === o ? c.value = u : 3 === o ? c.get = u : 4 === o && (c.set = u), n ? 1 === o ? (e.push(function (e, t) { - return u.get.call(e, t); - }), e.push(function (e, t) { - return u.set.call(e, t); - })) : 2 === o ? e.push(u) : e.push(function (e, t) { - return u.call(e, t); - }) : Object.defineProperty(t, r, c)); - } - function old_applyMemberDecs(e, t, a, r, o) { - for (var i, n, l = new Map(), s = new Map(), c = 0; c < o.length; c++) { - var d = o[c]; - if (Array.isArray(d)) { - var u, - f, - p, - v = d[1], - h = d[2], - y = d.length > 3, - b = v >= 5; - if (b ? (u = t, f = r, 0 !== (v -= 5) && (p = n = n || [])) : (u = t.prototype, f = a, 0 !== v && (p = i = i || [])), 0 !== v && !y) { - var g = b ? s : l, - m = g.get(h) || 0; - if (!0 === m || 3 === m && 4 !== v || 4 === m && 3 !== v) throw new Error("Attempted to decorate a public method/accessor that has the same name as a previously decorated public method/accessor. This is not currently supported by the decorators plugin. Property name was: " + h); - !m && v > 2 ? g.set(h, v) : g.set(h, !0); - } - old_applyMemberDec(e, u, d, h, v, b, y, f, p); - } - } - old_pushInitializers(e, i), old_pushInitializers(e, n); - } - function old_pushInitializers(e, t) { - t && e.push(function (e) { - for (var a = 0; a < t.length; a++) t[a].call(e); - return e; - }); - } - function old_applyClassDecs(e, t, a, r) { - if (r.length > 0) { - for (var o = [], i = t, n = t.name, l = r.length - 1; l >= 0; l--) { - var s = { - v: !1 - }; - try { - var c = Object.assign({ - kind: "class", - name: n, - addInitializer: old_createAddInitializerMethod(o, s) - }, old_createMetadataMethodsForProperty(a, 0, n, s)), - d = r[l](i, c); - } finally { - s.v = !0; - } - void 0 !== d && (old_assertValidReturnValue(10, d), i = d); - } - e.push(i, function () { - for (var e = 0; e < o.length; e++) o[e].call(i); - }); - } - } - function _applyDecs(e, t, a) { - var r = [], - o = {}, - i = {}; - return old_applyMemberDecs(r, e, i, o, t), old_convertMetadataMapToFinal(e.prototype, i), old_applyClassDecs(r, e, o, a), old_convertMetadataMapToFinal(e, o), r; - } - function applyDecs2203Factory() { - function createAddInitializerMethod(e, t) { - return function (r) { - !function (e, t) { - if (e.v) throw new Error("attempted to call " + t + " after decoration was finished"); - }(t, "addInitializer"), assertCallable(r, "An initializer"), e.push(r); - }; - } - function memberDec(e, t, r, a, n, i, s, o) { - var c; - switch (n) { - case 1: - c = "accessor"; - break; - case 2: - c = "method"; - break; - case 3: - c = "getter"; - break; - case 4: - c = "setter"; - break; - default: - c = "field"; - } - var l, - u, - f = { - kind: c, - name: s ? "#" + t : t, - static: i, - private: s - }, - p = { - v: !1 - }; - 0 !== n && (f.addInitializer = createAddInitializerMethod(a, p)), 0 === n ? s ? (l = r.get, u = r.set) : (l = function () { - return this[t]; - }, u = function (e) { - this[t] = e; - }) : 2 === n ? l = function () { - return r.value; - } : (1 !== n && 3 !== n || (l = function () { - return r.get.call(this); - }), 1 !== n && 4 !== n || (u = function (e) { - r.set.call(this, e); - })), f.access = l && u ? { - get: l, - set: u - } : l ? { - get: l - } : { - set: u - }; - try { - return e(o, f); - } finally { - p.v = !0; - } - } - function assertCallable(e, t) { - if ("function" != typeof e) throw new TypeError(t + " must be a function"); - } - function assertValidReturnValue(e, t) { - var r = typeof t; - if (1 === e) { - if ("object" !== r || null === t) throw new TypeError("accessor decorators must return an object with get, set, or init properties or void 0"); - void 0 !== t.get && assertCallable(t.get, "accessor.get"), void 0 !== t.set && assertCallable(t.set, "accessor.set"), void 0 !== t.init && assertCallable(t.init, "accessor.init"); - } else if ("function" !== r) { - var a; - throw a = 0 === e ? "field" : 10 === e ? "class" : "method", new TypeError(a + " decorators must return a function or void 0"); - } - } - function applyMemberDec(e, t, r, a, n, i, s, o) { - var c, - l, - u, - f, - p, - d, - h = r[0]; - if (s ? c = 0 === n || 1 === n ? { - get: r[3], - set: r[4] - } : 3 === n ? { - get: r[3] - } : 4 === n ? { - set: r[3] - } : { - value: r[3] - } : 0 !== n && (c = Object.getOwnPropertyDescriptor(t, a)), 1 === n ? u = { - get: c.get, - set: c.set - } : 2 === n ? u = c.value : 3 === n ? u = c.get : 4 === n && (u = c.set), "function" == typeof h) void 0 !== (f = memberDec(h, a, c, o, n, i, s, u)) && (assertValidReturnValue(n, f), 0 === n ? l = f : 1 === n ? (l = f.init, p = f.get || u.get, d = f.set || u.set, u = { - get: p, - set: d - }) : u = f);else for (var v = h.length - 1; v >= 0; v--) { - var g; - if (void 0 !== (f = memberDec(h[v], a, c, o, n, i, s, u))) assertValidReturnValue(n, f), 0 === n ? g = f : 1 === n ? (g = f.init, p = f.get || u.get, d = f.set || u.set, u = { - get: p, - set: d - }) : u = f, void 0 !== g && (void 0 === l ? l = g : "function" == typeof l ? l = [l, g] : l.push(g)); - } - if (0 === n || 1 === n) { - if (void 0 === l) l = function (e, t) { - return t; - };else if ("function" != typeof l) { - var y = l; - l = function (e, t) { - for (var r = t, a = 0; a < y.length; a++) r = y[a].call(e, r); - return r; - }; - } else { - var m = l; - l = function (e, t) { - return m.call(e, t); - }; - } - e.push(l); - } - 0 !== n && (1 === n ? (c.get = u.get, c.set = u.set) : 2 === n ? c.value = u : 3 === n ? c.get = u : 4 === n && (c.set = u), s ? 1 === n ? (e.push(function (e, t) { - return u.get.call(e, t); - }), e.push(function (e, t) { - return u.set.call(e, t); - })) : 2 === n ? e.push(u) : e.push(function (e, t) { - return u.call(e, t); - }) : Object.defineProperty(t, a, c)); - } - function pushInitializers(e, t) { - t && e.push(function (e) { - for (var r = 0; r < t.length; r++) t[r].call(e); - return e; - }); - } - return function (e, t, r) { - var a = []; - return function (e, t, r) { - for (var a, n, i = new Map(), s = new Map(), o = 0; o < r.length; o++) { - var c = r[o]; - if (Array.isArray(c)) { - var l, - u, - f = c[1], - p = c[2], - d = c.length > 3, - h = f >= 5; - if (h ? (l = t, 0 != (f -= 5) && (u = n = n || [])) : (l = t.prototype, 0 !== f && (u = a = a || [])), 0 !== f && !d) { - var v = h ? s : i, - g = v.get(p) || 0; - if (!0 === g || 3 === g && 4 !== f || 4 === g && 3 !== f) throw new Error("Attempted to decorate a public method/accessor that has the same name as a previously decorated public method/accessor. This is not currently supported by the decorators plugin. Property name was: " + p); - !g && f > 2 ? v.set(p, f) : v.set(p, !0); - } - applyMemberDec(e, l, c, p, f, h, d, u); - } - } - pushInitializers(e, a), pushInitializers(e, n); - }(a, e, t), function (e, t, r) { - if (r.length > 0) { - for (var a = [], n = t, i = t.name, s = r.length - 1; s >= 0; s--) { - var o = { - v: !1 - }; - try { - var c = r[s](n, { - kind: "class", - name: i, - addInitializer: createAddInitializerMethod(a, o) - }); - } finally { - o.v = !0; - } - void 0 !== c && (assertValidReturnValue(10, c), n = c); - } - e.push(n, function () { - for (var e = 0; e < a.length; e++) a[e].call(n); - }); - } - }(a, e, r), a; - }; - } - var applyDecs2203Impl; - function _applyDecs2203(e, t, r) { - return (applyDecs2203Impl = applyDecs2203Impl || applyDecs2203Factory())(e, t, r); - } - function applyDecs2203RFactory() { - function createAddInitializerMethod(e, t) { - return function (r) { - !function (e, t) { - if (e.v) throw new Error("attempted to call " + t + " after decoration was finished"); - }(t, "addInitializer"), assertCallable(r, "An initializer"), e.push(r); - }; - } - function memberDec(e, t, r, n, a, i, s, o) { - var c; - switch (a) { - case 1: - c = "accessor"; - break; - case 2: - c = "method"; - break; - case 3: - c = "getter"; - break; - case 4: - c = "setter"; - break; - default: - c = "field"; - } - var l, - u, - f = { - kind: c, - name: s ? "#" + t : t, - static: i, - private: s - }, - p = { - v: !1 - }; - 0 !== a && (f.addInitializer = createAddInitializerMethod(n, p)), 0 === a ? s ? (l = r.get, u = r.set) : (l = function () { - return this[t]; - }, u = function (e) { - this[t] = e; - }) : 2 === a ? l = function () { - return r.value; - } : (1 !== a && 3 !== a || (l = function () { - return r.get.call(this); - }), 1 !== a && 4 !== a || (u = function (e) { - r.set.call(this, e); - })), f.access = l && u ? { - get: l, - set: u - } : l ? { - get: l - } : { - set: u - }; - try { - return e(o, f); - } finally { - p.v = !0; - } - } - function assertCallable(e, t) { - if ("function" != typeof e) throw new TypeError(t + " must be a function"); - } - function assertValidReturnValue(e, t) { - var r = typeof t; - if (1 === e) { - if ("object" !== r || null === t) throw new TypeError("accessor decorators must return an object with get, set, or init properties or void 0"); - void 0 !== t.get && assertCallable(t.get, "accessor.get"), void 0 !== t.set && assertCallable(t.set, "accessor.set"), void 0 !== t.init && assertCallable(t.init, "accessor.init"); - } else if ("function" !== r) { - var n; - throw n = 0 === e ? "field" : 10 === e ? "class" : "method", new TypeError(n + " decorators must return a function or void 0"); - } - } - function applyMemberDec(e, t, r, n, a, i, s, o) { - var c, - l, - u, - f, - p, - d, - h = r[0]; - if (s ? c = 0 === a || 1 === a ? { - get: r[3], - set: r[4] - } : 3 === a ? { - get: r[3] - } : 4 === a ? { - set: r[3] - } : { - value: r[3] - } : 0 !== a && (c = Object.getOwnPropertyDescriptor(t, n)), 1 === a ? u = { - get: c.get, - set: c.set - } : 2 === a ? u = c.value : 3 === a ? u = c.get : 4 === a && (u = c.set), "function" == typeof h) void 0 !== (f = memberDec(h, n, c, o, a, i, s, u)) && (assertValidReturnValue(a, f), 0 === a ? l = f : 1 === a ? (l = f.init, p = f.get || u.get, d = f.set || u.set, u = { - get: p, - set: d - }) : u = f);else for (var v = h.length - 1; v >= 0; v--) { - var g; - if (void 0 !== (f = memberDec(h[v], n, c, o, a, i, s, u))) assertValidReturnValue(a, f), 0 === a ? g = f : 1 === a ? (g = f.init, p = f.get || u.get, d = f.set || u.set, u = { - get: p, - set: d - }) : u = f, void 0 !== g && (void 0 === l ? l = g : "function" == typeof l ? l = [l, g] : l.push(g)); - } - if (0 === a || 1 === a) { - if (void 0 === l) l = function (e, t) { - return t; - };else if ("function" != typeof l) { - var y = l; - l = function (e, t) { - for (var r = t, n = 0; n < y.length; n++) r = y[n].call(e, r); - return r; - }; - } else { - var m = l; - l = function (e, t) { - return m.call(e, t); - }; - } - e.push(l); - } - 0 !== a && (1 === a ? (c.get = u.get, c.set = u.set) : 2 === a ? c.value = u : 3 === a ? c.get = u : 4 === a && (c.set = u), s ? 1 === a ? (e.push(function (e, t) { - return u.get.call(e, t); - }), e.push(function (e, t) { - return u.set.call(e, t); - })) : 2 === a ? e.push(u) : e.push(function (e, t) { - return u.call(e, t); - }) : Object.defineProperty(t, n, c)); - } - function applyMemberDecs(e, t) { - for (var r, n, a = [], i = new Map(), s = new Map(), o = 0; o < t.length; o++) { - var c = t[o]; - if (Array.isArray(c)) { - var l, - u, - f = c[1], - p = c[2], - d = c.length > 3, - h = f >= 5; - if (h ? (l = e, 0 !== (f -= 5) && (u = n = n || [])) : (l = e.prototype, 0 !== f && (u = r = r || [])), 0 !== f && !d) { - var v = h ? s : i, - g = v.get(p) || 0; - if (!0 === g || 3 === g && 4 !== f || 4 === g && 3 !== f) throw new Error("Attempted to decorate a public method/accessor that has the same name as a previously decorated public method/accessor. This is not currently supported by the decorators plugin. Property name was: " + p); - !g && f > 2 ? v.set(p, f) : v.set(p, !0); - } - applyMemberDec(a, l, c, p, f, h, d, u); - } - } - return pushInitializers(a, r), pushInitializers(a, n), a; - } - function pushInitializers(e, t) { - t && e.push(function (e) { - for (var r = 0; r < t.length; r++) t[r].call(e); - return e; - }); - } - return function (e, t, r) { - return { - e: applyMemberDecs(e, t), - get c() { - return function (e, t) { - if (t.length > 0) { - for (var r = [], n = e, a = e.name, i = t.length - 1; i >= 0; i--) { - var s = { - v: !1 - }; - try { - var o = t[i](n, { - kind: "class", - name: a, - addInitializer: createAddInitializerMethod(r, s) - }); - } finally { - s.v = !0; - } - void 0 !== o && (assertValidReturnValue(10, o), n = o); - } - return [n, function () { - for (var e = 0; e < r.length; e++) r[e].call(n); - }]; - } - }(e, r); - } - }; - }; - } - function _applyDecs2203R(e, t, r) { - return (_applyDecs2203R = applyDecs2203RFactory())(e, t, r); - } - function applyDecs2301Factory() { - function createAddInitializerMethod(e, t) { - return function (r) { - !function (e, t) { - if (e.v) throw new Error("attempted to call " + t + " after decoration was finished"); - }(t, "addInitializer"), assertCallable(r, "An initializer"), e.push(r); - }; - } - function assertInstanceIfPrivate(e, t) { - if (!e(t)) throw new TypeError("Attempted to access private element on non-instance"); - } - function memberDec(e, t, r, n, a, i, s, o, c) { - var u; - switch (a) { - case 1: - u = "accessor"; - break; - case 2: - u = "method"; - break; - case 3: - u = "getter"; - break; - case 4: - u = "setter"; - break; - default: - u = "field"; - } - var l, - f, - p = { - kind: u, - name: s ? "#" + t : t, - static: i, - private: s - }, - d = { - v: !1 - }; - if (0 !== a && (p.addInitializer = createAddInitializerMethod(n, d)), s || 0 !== a && 2 !== a) { - if (2 === a) l = function (e) { - return assertInstanceIfPrivate(c, e), r.value; - };else { - var h = 0 === a || 1 === a; - (h || 3 === a) && (l = s ? function (e) { - return assertInstanceIfPrivate(c, e), r.get.call(e); - } : function (e) { - return r.get.call(e); - }), (h || 4 === a) && (f = s ? function (e, t) { - assertInstanceIfPrivate(c, e), r.set.call(e, t); - } : function (e, t) { - r.set.call(e, t); - }); - } - } else l = function (e) { - return e[t]; - }, 0 === a && (f = function (e, r) { - e[t] = r; - }); - var v = s ? c.bind() : function (e) { - return t in e; - }; - p.access = l && f ? { - get: l, - set: f, - has: v - } : l ? { - get: l, - has: v - } : { - set: f, - has: v - }; - try { - return e(o, p); - } finally { - d.v = !0; - } - } - function assertCallable(e, t) { - if ("function" != typeof e) throw new TypeError(t + " must be a function"); - } - function assertValidReturnValue(e, t) { - var r = typeof t; - if (1 === e) { - if ("object" !== r || null === t) throw new TypeError("accessor decorators must return an object with get, set, or init properties or void 0"); - void 0 !== t.get && assertCallable(t.get, "accessor.get"), void 0 !== t.set && assertCallable(t.set, "accessor.set"), void 0 !== t.init && assertCallable(t.init, "accessor.init"); - } else if ("function" !== r) { - var n; - throw n = 0 === e ? "field" : 10 === e ? "class" : "method", new TypeError(n + " decorators must return a function or void 0"); - } - } - function curryThis2(e) { - return function (t) { - e(this, t); - }; - } - function applyMemberDec(e, t, r, n, a, i, s, o, c) { - var u, - l, - f, - p, - d, - h, - v, - g = r[0]; - if (s ? u = 0 === a || 1 === a ? { - get: (p = r[3], function () { - return p(this); - }), - set: curryThis2(r[4]) - } : 3 === a ? { - get: r[3] - } : 4 === a ? { - set: r[3] - } : { - value: r[3] - } : 0 !== a && (u = Object.getOwnPropertyDescriptor(t, n)), 1 === a ? f = { - get: u.get, - set: u.set - } : 2 === a ? f = u.value : 3 === a ? f = u.get : 4 === a && (f = u.set), "function" == typeof g) void 0 !== (d = memberDec(g, n, u, o, a, i, s, f, c)) && (assertValidReturnValue(a, d), 0 === a ? l = d : 1 === a ? (l = d.init, h = d.get || f.get, v = d.set || f.set, f = { - get: h, - set: v - }) : f = d);else for (var y = g.length - 1; y >= 0; y--) { - var m; - if (void 0 !== (d = memberDec(g[y], n, u, o, a, i, s, f, c))) assertValidReturnValue(a, d), 0 === a ? m = d : 1 === a ? (m = d.init, h = d.get || f.get, v = d.set || f.set, f = { - get: h, - set: v - }) : f = d, void 0 !== m && (void 0 === l ? l = m : "function" == typeof l ? l = [l, m] : l.push(m)); - } - if (0 === a || 1 === a) { - if (void 0 === l) l = function (e, t) { - return t; - };else if ("function" != typeof l) { - var b = l; - l = function (e, t) { - for (var r = t, n = 0; n < b.length; n++) r = b[n].call(e, r); - return r; - }; - } else { - var I = l; - l = function (e, t) { - return I.call(e, t); - }; - } - e.push(l); - } - 0 !== a && (1 === a ? (u.get = f.get, u.set = f.set) : 2 === a ? u.value = f : 3 === a ? u.get = f : 4 === a && (u.set = f), s ? 1 === a ? (e.push(function (e, t) { - return f.get.call(e, t); - }), e.push(function (e, t) { - return f.set.call(e, t); - })) : 2 === a ? e.push(f) : e.push(function (e, t) { - return f.call(e, t); - }) : Object.defineProperty(t, n, u)); - } - function applyMemberDecs(e, t, r) { - for (var n, a, i, s = [], o = new Map(), c = new Map(), u = 0; u < t.length; u++) { - var l = t[u]; - if (Array.isArray(l)) { - var f, - p, - d = l[1], - h = l[2], - v = l.length > 3, - g = d >= 5, - y = r; - if (g ? (f = e, 0 !== (d -= 5) && (p = a = a || []), v && !i && (i = function (t) { - return _checkInRHS(t) === e; - }), y = i) : (f = e.prototype, 0 !== d && (p = n = n || [])), 0 !== d && !v) { - var m = g ? c : o, - b = m.get(h) || 0; - if (!0 === b || 3 === b && 4 !== d || 4 === b && 3 !== d) throw new Error("Attempted to decorate a public method/accessor that has the same name as a previously decorated public method/accessor. This is not currently supported by the decorators plugin. Property name was: " + h); - !b && d > 2 ? m.set(h, d) : m.set(h, !0); - } - applyMemberDec(s, f, l, h, d, g, v, p, y); - } - } - return pushInitializers(s, n), pushInitializers(s, a), s; - } - function pushInitializers(e, t) { - t && e.push(function (e) { - for (var r = 0; r < t.length; r++) t[r].call(e); - return e; - }); - } - return function (e, t, r, n) { - return { - e: applyMemberDecs(e, t, n), - get c() { - return function (e, t) { - if (t.length > 0) { - for (var r = [], n = e, a = e.name, i = t.length - 1; i >= 0; i--) { - var s = { - v: !1 - }; - try { - var o = t[i](n, { - kind: "class", - name: a, - addInitializer: createAddInitializerMethod(r, s) - }); - } finally { - s.v = !0; - } - void 0 !== o && (assertValidReturnValue(10, o), n = o); - } - return [n, function () { - for (var e = 0; e < r.length; e++) r[e].call(n); - }]; - } - }(e, r); - } - }; - }; - } - function _applyDecs2301(e, t, r, n) { - return (_applyDecs2301 = applyDecs2301Factory())(e, t, r, n); - } - function createAddInitializerMethod(e, t) { - return function (r) { - assertNotFinished(t, "addInitializer"), assertCallable(r, "An initializer"), e.push(r); - }; - } - function assertInstanceIfPrivate(e, t) { - if (!e(t)) throw new TypeError("Attempted to access private element on non-instance"); - } - function memberDec(e, t, r, a, n, i, s, o, c, l, u) { - var f; - switch (i) { - case 1: - f = "accessor"; - break; - case 2: - f = "method"; - break; - case 3: - f = "getter"; - break; - case 4: - f = "setter"; - break; - default: - f = "field"; - } - var d, - p, - h = { - kind: f, - name: o ? "#" + r : r, - static: s, - private: o, - metadata: u - }, - v = { - v: !1 - }; - if (0 !== i && (h.addInitializer = createAddInitializerMethod(n, v)), o || 0 !== i && 2 !== i) { - if (2 === i) d = function (e) { - return assertInstanceIfPrivate(l, e), a.value; - };else { - var y = 0 === i || 1 === i; - (y || 3 === i) && (d = o ? function (e) { - return assertInstanceIfPrivate(l, e), a.get.call(e); - } : function (e) { - return a.get.call(e); - }), (y || 4 === i) && (p = o ? function (e, t) { - assertInstanceIfPrivate(l, e), a.set.call(e, t); - } : function (e, t) { - a.set.call(e, t); - }); - } - } else d = function (e) { - return e[r]; - }, 0 === i && (p = function (e, t) { - e[r] = t; - }); - var m = o ? l.bind() : function (e) { - return r in e; - }; - h.access = d && p ? { - get: d, - set: p, - has: m - } : d ? { - get: d, - has: m - } : { - set: p, - has: m - }; - try { - return e.call(t, c, h); - } finally { - v.v = !0; - } - } - function assertNotFinished(e, t) { - if (e.v) throw new Error("attempted to call " + t + " after decoration was finished"); - } - function assertCallable(e, t) { - if ("function" != typeof e) throw new TypeError(t + " must be a function"); - } - function assertValidReturnValue(e, t) { - var r = typeof t; - if (1 === e) { - if ("object" !== r || null === t) throw new TypeError("accessor decorators must return an object with get, set, or init properties or void 0"); - void 0 !== t.get && assertCallable(t.get, "accessor.get"), void 0 !== t.set && assertCallable(t.set, "accessor.set"), void 0 !== t.init && assertCallable(t.init, "accessor.init"); - } else if ("function" !== r) { - var a; - throw a = 0 === e ? "field" : 5 === e ? "class" : "method", new TypeError(a + " decorators must return a function or void 0"); - } - } - function curryThis1(e) { - return function () { - return e(this); - }; - } - function curryThis2(e) { - return function (t) { - e(this, t); - }; - } - function applyMemberDec(e, t, r, a, n, i, s, o, c, l, u) { - var f, - d, - p, - h, - v, - y, - m = r[0]; - a || Array.isArray(m) || (m = [m]), o ? f = 0 === i || 1 === i ? { - get: curryThis1(r[3]), - set: curryThis2(r[4]) - } : 3 === i ? { - get: r[3] - } : 4 === i ? { - set: r[3] - } : { - value: r[3] - } : 0 !== i && (f = Object.getOwnPropertyDescriptor(t, n)), 1 === i ? p = { - get: f.get, - set: f.set - } : 2 === i ? p = f.value : 3 === i ? p = f.get : 4 === i && (p = f.set); - for (var g = a ? 2 : 1, b = m.length - 1; b >= 0; b -= g) { - var I; - if (void 0 !== (h = memberDec(m[b], a ? m[b - 1] : void 0, n, f, c, i, s, o, p, l, u))) assertValidReturnValue(i, h), 0 === i ? I = h : 1 === i ? (I = h.init, v = h.get || p.get, y = h.set || p.set, p = { - get: v, - set: y - }) : p = h, void 0 !== I && (void 0 === d ? d = I : "function" == typeof d ? d = [d, I] : d.push(I)); - } - if (0 === i || 1 === i) { - if (void 0 === d) d = function (e, t) { - return t; - };else if ("function" != typeof d) { - var w = d; - d = function (e, t) { - for (var r = t, a = w.length - 1; a >= 0; a--) r = w[a].call(e, r); - return r; - }; - } else { - var M = d; - d = function (e, t) { - return M.call(e, t); - }; - } - e.push(d); - } - 0 !== i && (1 === i ? (f.get = p.get, f.set = p.set) : 2 === i ? f.value = p : 3 === i ? f.get = p : 4 === i && (f.set = p), o ? 1 === i ? (e.push(function (e, t) { - return p.get.call(e, t); - }), e.push(function (e, t) { - return p.set.call(e, t); - })) : 2 === i ? e.push(p) : e.push(function (e, t) { - return p.call(e, t); - }) : Object.defineProperty(t, n, f)); - } - function applyMemberDecs(e, t, r, a) { - for (var n, i, s, o = [], c = new Map(), l = new Map(), u = 0; u < t.length; u++) { - var f = t[u]; - if (Array.isArray(f)) { - var d, - p, - h = f[1], - v = f[2], - y = f.length > 3, - m = 16 & h, - g = !!(8 & h), - b = r; - if (h &= 7, g ? (d = e, 0 !== h && (p = i = i || []), y && !s && (s = function (t) { - return _checkInRHS(t) === e; - }), b = s) : (d = e.prototype, 0 !== h && (p = n = n || [])), 0 !== h && !y) { - var I = g ? l : c, - w = I.get(v) || 0; - if (!0 === w || 3 === w && 4 !== h || 4 === w && 3 !== h) throw new Error("Attempted to decorate a public method/accessor that has the same name as a previously decorated public method/accessor. This is not currently supported by the decorators plugin. Property name was: " + v); - I.set(v, !(!w && h > 2) || h); - } - applyMemberDec(o, d, f, m, v, h, g, y, p, b, a); - } - } - return pushInitializers(o, n), pushInitializers(o, i), o; - } - function pushInitializers(e, t) { - t && e.push(function (e) { - for (var r = 0; r < t.length; r++) t[r].call(e); - return e; - }); - } - function applyClassDecs(e, t, r, a) { - if (t.length) { - for (var n = [], i = e, s = e.name, o = r ? 2 : 1, c = t.length - 1; c >= 0; c -= o) { - var l = { - v: !1 - }; - try { - var u = t[c].call(r ? t[c - 1] : void 0, i, { - kind: "class", - name: s, - addInitializer: createAddInitializerMethod(n, l), - metadata: a - }); - } finally { - l.v = !0; - } - void 0 !== u && (assertValidReturnValue(5, u), i = u); - } - return [defineMetadata(i, a), function () { - for (var e = 0; e < n.length; e++) n[e].call(i); - }]; - } - } - function defineMetadata(e, t) { - return Object.defineProperty(e, Symbol.metadata || Symbol.for("Symbol.metadata"), { - configurable: !0, - enumerable: !0, - value: t - }); - } - function _applyDecs2305(e, t, r, a, n, i) { - if (arguments.length >= 6) var s = i[Symbol.metadata || Symbol.for("Symbol.metadata")]; - var o = Object.create(void 0 === s ? null : s), - c = applyMemberDecs(e, t, n, o); - return r.length || defineMetadata(e, o), { - e: c, - get c() { - return applyClassDecs(e, r, a, o); - } - }; - } - function _asyncGeneratorDelegate(t) { - var e = {}, - n = !1; - function pump(e, r) { - return n = !0, r = new Promise(function (n) { - n(t[e](r)); - }), { - done: !1, - value: new _OverloadYield(r, 1) - }; - } - return e["undefined" != typeof Symbol && Symbol.iterator || "@@iterator"] = function () { - return this; - }, e.next = function (t) { - return n ? (n = !1, t) : pump("next", t); - }, "function" == typeof t.throw && (e.throw = function (t) { - if (n) throw n = !1, t; - return pump("throw", t); - }), "function" == typeof t.return && (e.return = function (t) { - return n ? (n = !1, t) : pump("return", t); - }), e; - } - function _asyncIterator(r) { - var n, - t, - o, - e = 2; - for ("undefined" != typeof Symbol && (t = Symbol.asyncIterator, o = Symbol.iterator); e--;) { - if (t && null != (n = r[t])) return n.call(r); - if (o && null != (n = r[o])) return new AsyncFromSyncIterator(n.call(r)); - t = "@@asyncIterator", o = "@@iterator"; - } - throw new TypeError("Object is not async iterable"); - } - function AsyncFromSyncIterator(r) { - function AsyncFromSyncIteratorContinuation(r) { - if (Object(r) !== r) return Promise.reject(new TypeError(r + " is not an object.")); - var n = r.done; - return Promise.resolve(r.value).then(function (r) { - return { - value: r, - done: n - }; - }); - } - return AsyncFromSyncIterator = function (r) { - this.s = r, this.n = r.next; - }, AsyncFromSyncIterator.prototype = { - s: null, - n: null, - next: function () { - return AsyncFromSyncIteratorContinuation(this.n.apply(this.s, arguments)); - }, - return: function (r) { - var n = this.s.return; - return void 0 === n ? Promise.resolve({ - value: r, - done: !0 - }) : AsyncFromSyncIteratorContinuation(n.apply(this.s, arguments)); - }, - throw: function (r) { - var n = this.s.return; - return void 0 === n ? Promise.reject(r) : AsyncFromSyncIteratorContinuation(n.apply(this.s, arguments)); - } - }, new AsyncFromSyncIterator(r); - } - function _awaitAsyncGenerator(e) { - return new _OverloadYield(e, 0); - } - function _checkInRHS(e) { - if (Object(e) !== e) throw TypeError("right-hand side of 'in' should be an object, got " + (null !== e ? typeof e : "null")); - return e; - } - function _defineAccessor(e, r, n, t) { - var c = { - configurable: !0, - enumerable: !0 - }; - return c[e] = t, Object.defineProperty(r, n, c); - } - function dispose_SuppressedError(r, e) { - return "undefined" != typeof SuppressedError ? dispose_SuppressedError = SuppressedError : (dispose_SuppressedError = function (r, e) { - this.suppressed = r, this.error = e, this.stack = new Error().stack; - }, dispose_SuppressedError.prototype = Object.create(Error.prototype, { - constructor: { - value: dispose_SuppressedError, - writable: !0, - configurable: !0 - } - })), new dispose_SuppressedError(r, e); - } - function _dispose(r, e, s) { - function next() { - for (; r.length > 0;) try { - var o = r.pop(), - p = o.d.call(o.v); - if (o.a) return Promise.resolve(p).then(next, err); - } catch (r) { - return err(r); - } - if (s) throw e; - } - function err(r) { - return e = s ? new dispose_SuppressedError(r, e) : r, s = !0, next(); - } - return next(); - } - function _importDeferProxy(e) { - var t = null, - constValue = function (e) { - return function () { - return e; - }; - }, - proxy = function (r) { - return function (n, o, f) { - return null === t && (t = e()), r(t, o, f); - }; - }; - return new Proxy({}, { - defineProperty: constValue(!1), - deleteProperty: constValue(!1), - get: proxy(Reflect.get), - getOwnPropertyDescriptor: proxy(Reflect.getOwnPropertyDescriptor), - getPrototypeOf: constValue(null), - isExtensible: constValue(!1), - has: proxy(Reflect.has), - ownKeys: proxy(Reflect.ownKeys), - preventExtensions: constValue(!0), - set: constValue(!1), - setPrototypeOf: constValue(!1) - }); - } - function _iterableToArrayLimit(r, l) { - var t = null == r ? null : "undefined" != typeof Symbol && r[Symbol.iterator] || r["@@iterator"]; - if (null != t) { - var e, - n, - i, - u, - a = [], - f = !0, - o = !1; - try { - if (i = (t = t.call(r)).next, 0 === l) { - if (Object(t) !== t) return; - f = !1; - } else for (; !(f = (e = i.call(t)).done) && (a.push(e.value), a.length !== l); f = !0); - } catch (r) { - o = !0, n = r; - } finally { - try { - if (!f && null != t.return && (u = t.return(), Object(u) !== u)) return; - } finally { - if (o) throw n; - } - } - return a; - } - } - function _iterableToArrayLimitLoose(e, r) { - var t = e && ("undefined" != typeof Symbol && e[Symbol.iterator] || e["@@iterator"]); - if (null != t) { - var o, - l = []; - for (t = t.call(e); e.length < r && !(o = t.next()).done;) l.push(o.value); - return l; - } - } - var REACT_ELEMENT_TYPE; - function _jsx(e, r, E, l) { - REACT_ELEMENT_TYPE || (REACT_ELEMENT_TYPE = "function" == typeof Symbol && Symbol.for && Symbol.for("react.element") || 60103); - var o = e && e.defaultProps, - n = arguments.length - 3; - if (r || 0 === n || (r = { - children: void 0 - }), 1 === n) r.children = l;else if (n > 1) { - for (var t = new Array(n), f = 0; f < n; f++) t[f] = arguments[f + 3]; - r.children = t; - } - if (r && o) for (var i in o) void 0 === r[i] && (r[i] = o[i]);else r || (r = o || {}); - return { - $$typeof: REACT_ELEMENT_TYPE, - type: e, - key: void 0 === E ? null : "" + E, - ref: null, - props: r, - _owner: null - }; - } - function ownKeys(e, r) { - var t = Object.keys(e); - if (Object.getOwnPropertySymbols) { - var o = Object.getOwnPropertySymbols(e); - r && (o = o.filter(function (r) { - return Object.getOwnPropertyDescriptor(e, r).enumerable; - })), t.push.apply(t, o); - } - return t; - } - function _objectSpread2(e) { - for (var r = 1; r < arguments.length; r++) { - var t = null != arguments[r] ? arguments[r] : {}; - r % 2 ? ownKeys(Object(t), !0).forEach(function (r) { - _defineProperty(e, r, t[r]); - }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(t)) : ownKeys(Object(t)).forEach(function (r) { - Object.defineProperty(e, r, Object.getOwnPropertyDescriptor(t, r)); - }); - } - return e; - } - function _regeneratorRuntime() { - "use strict"; /*! regenerator-runtime -- Copyright (c) 2014-present, Facebook, Inc. -- license (MIT): https://github.com/facebook/regenerator/blob/main/LICENSE */ - _regeneratorRuntime = function () { - return e; - }; - var t, - e = {}, - r = Object.prototype, - n = r.hasOwnProperty, - o = Object.defineProperty || function (t, e, r) { - t[e] = r.value; - }, - i = "function" == typeof Symbol ? Symbol : {}, - a = i.iterator || "@@iterator", - c = i.asyncIterator || "@@asyncIterator", - u = i.toStringTag || "@@toStringTag"; - function define(t, e, r) { - return Object.defineProperty(t, e, { - value: r, - enumerable: !0, - configurable: !0, - writable: !0 - }), t[e]; - } - try { - define({}, ""); - } catch (t) { - define = function (t, e, r) { - return t[e] = r; - }; - } - function wrap(t, e, r, n) { - var i = e && e.prototype instanceof Generator ? e : Generator, - a = Object.create(i.prototype), - c = new Context(n || []); - return o(a, "_invoke", { - value: makeInvokeMethod(t, r, c) - }), a; - } - function tryCatch(t, e, r) { - try { - return { - type: "normal", - arg: t.call(e, r) - }; - } catch (t) { - return { - type: "throw", - arg: t - }; - } - } - e.wrap = wrap; - var h = "suspendedStart", - l = "suspendedYield", - f = "executing", - s = "completed", - y = {}; - function Generator() {} - function GeneratorFunction() {} - function GeneratorFunctionPrototype() {} - var p = {}; - define(p, a, function () { - return this; - }); - var d = Object.getPrototypeOf, - v = d && d(d(values([]))); - v && v !== r && n.call(v, a) && (p = v); - var g = GeneratorFunctionPrototype.prototype = Generator.prototype = Object.create(p); - function defineIteratorMethods(t) { - ["next", "throw", "return"].forEach(function (e) { - define(t, e, function (t) { - return this._invoke(e, t); - }); - }); - } - function AsyncIterator(t, e) { - function invoke(r, o, i, a) { - var c = tryCatch(t[r], t, o); - if ("throw" !== c.type) { - var u = c.arg, - h = u.value; - return h && "object" == typeof h && n.call(h, "__await") ? e.resolve(h.__await).then(function (t) { - invoke("next", t, i, a); - }, function (t) { - invoke("throw", t, i, a); - }) : e.resolve(h).then(function (t) { - u.value = t, i(u); - }, function (t) { - return invoke("throw", t, i, a); - }); - } - a(c.arg); - } - var r; - o(this, "_invoke", { - value: function (t, n) { - function callInvokeWithMethodAndArg() { - return new e(function (e, r) { - invoke(t, n, e, r); - }); - } - return r = r ? r.then(callInvokeWithMethodAndArg, callInvokeWithMethodAndArg) : callInvokeWithMethodAndArg(); - } - }); - } - function makeInvokeMethod(e, r, n) { - var o = h; - return function (i, a) { - if (o === f) throw new Error("Generator is already running"); - if (o === s) { - if ("throw" === i) throw a; - return { - value: t, - done: !0 - }; - } - for (n.method = i, n.arg = a;;) { - var c = n.delegate; - if (c) { - var u = maybeInvokeDelegate(c, n); - if (u) { - if (u === y) continue; - return u; - } - } - if ("next" === n.method) n.sent = n._sent = n.arg;else if ("throw" === n.method) { - if (o === h) throw o = s, n.arg; - n.dispatchException(n.arg); - } else "return" === n.method && n.abrupt("return", n.arg); - o = f; - var p = tryCatch(e, r, n); - if ("normal" === p.type) { - if (o = n.done ? s : l, p.arg === y) continue; - return { - value: p.arg, - done: n.done - }; - } - "throw" === p.type && (o = s, n.method = "throw", n.arg = p.arg); - } - }; - } - function maybeInvokeDelegate(e, r) { - var n = r.method, - o = e.iterator[n]; - if (o === t) return r.delegate = null, "throw" === n && e.iterator.return && (r.method = "return", r.arg = t, maybeInvokeDelegate(e, r), "throw" === r.method) || "return" !== n && (r.method = "throw", r.arg = new TypeError("The iterator does not provide a '" + n + "' method")), y; - var i = tryCatch(o, e.iterator, r.arg); - if ("throw" === i.type) return r.method = "throw", r.arg = i.arg, r.delegate = null, y; - var a = i.arg; - return a ? a.done ? (r[e.resultName] = a.value, r.next = e.nextLoc, "return" !== r.method && (r.method = "next", r.arg = t), r.delegate = null, y) : a : (r.method = "throw", r.arg = new TypeError("iterator result is not an object"), r.delegate = null, y); - } - function pushTryEntry(t) { - var e = { - tryLoc: t[0] - }; - 1 in t && (e.catchLoc = t[1]), 2 in t && (e.finallyLoc = t[2], e.afterLoc = t[3]), this.tryEntries.push(e); - } - function resetTryEntry(t) { - var e = t.completion || {}; - e.type = "normal", delete e.arg, t.completion = e; - } - function Context(t) { - this.tryEntries = [{ - tryLoc: "root" - }], t.forEach(pushTryEntry, this), this.reset(!0); - } - function values(e) { - if (e || "" === e) { - var r = e[a]; - if (r) return r.call(e); - if ("function" == typeof e.next) return e; - if (!isNaN(e.length)) { - var o = -1, - i = function next() { - for (; ++o < e.length;) if (n.call(e, o)) return next.value = e[o], next.done = !1, next; - return next.value = t, next.done = !0, next; - }; - return i.next = i; - } - } - throw new TypeError(typeof e + " is not iterable"); - } - return GeneratorFunction.prototype = GeneratorFunctionPrototype, o(g, "constructor", { - value: GeneratorFunctionPrototype, - configurable: !0 - }), o(GeneratorFunctionPrototype, "constructor", { - value: GeneratorFunction, - configurable: !0 - }), GeneratorFunction.displayName = define(GeneratorFunctionPrototype, u, "GeneratorFunction"), e.isGeneratorFunction = function (t) { - var e = "function" == typeof t && t.constructor; - return !!e && (e === GeneratorFunction || "GeneratorFunction" === (e.displayName || e.name)); - }, e.mark = function (t) { - return Object.setPrototypeOf ? Object.setPrototypeOf(t, GeneratorFunctionPrototype) : (t.__proto__ = GeneratorFunctionPrototype, define(t, u, "GeneratorFunction")), t.prototype = Object.create(g), t; - }, e.awrap = function (t) { - return { - __await: t - }; - }, defineIteratorMethods(AsyncIterator.prototype), define(AsyncIterator.prototype, c, function () { - return this; - }), e.AsyncIterator = AsyncIterator, e.async = function (t, r, n, o, i) { - void 0 === i && (i = Promise); - var a = new AsyncIterator(wrap(t, r, n, o), i); - return e.isGeneratorFunction(r) ? a : a.next().then(function (t) { - return t.done ? t.value : a.next(); - }); - }, defineIteratorMethods(g), define(g, u, "Generator"), define(g, a, function () { - return this; - }), define(g, "toString", function () { - return "[object Generator]"; - }), e.keys = function (t) { - var e = Object(t), - r = []; - for (var n in e) r.push(n); - return r.reverse(), function next() { - for (; r.length;) { - var t = r.pop(); - if (t in e) return next.value = t, next.done = !1, next; - } - return next.done = !0, next; - }; - }, e.values = values, Context.prototype = { - constructor: Context, - reset: function (e) { - if (this.prev = 0, this.next = 0, this.sent = this._sent = t, this.done = !1, this.delegate = null, this.method = "next", this.arg = t, this.tryEntries.forEach(resetTryEntry), !e) for (var r in this) "t" === r.charAt(0) && n.call(this, r) && !isNaN(+r.slice(1)) && (this[r] = t); - }, - stop: function () { - this.done = !0; - var t = this.tryEntries[0].completion; - if ("throw" === t.type) throw t.arg; - return this.rval; - }, - dispatchException: function (e) { - if (this.done) throw e; - var r = this; - function handle(n, o) { - return a.type = "throw", a.arg = e, r.next = n, o && (r.method = "next", r.arg = t), !!o; - } - for (var o = this.tryEntries.length - 1; o >= 0; --o) { - var i = this.tryEntries[o], - a = i.completion; - if ("root" === i.tryLoc) return handle("end"); - if (i.tryLoc <= this.prev) { - var c = n.call(i, "catchLoc"), - u = n.call(i, "finallyLoc"); - if (c && u) { - if (this.prev < i.catchLoc) return handle(i.catchLoc, !0); - if (this.prev < i.finallyLoc) return handle(i.finallyLoc); - } else if (c) { - if (this.prev < i.catchLoc) return handle(i.catchLoc, !0); - } else { - if (!u) throw new Error("try statement without catch or finally"); - if (this.prev < i.finallyLoc) return handle(i.finallyLoc); - } - } - } - }, - abrupt: function (t, e) { - for (var r = this.tryEntries.length - 1; r >= 0; --r) { - var o = this.tryEntries[r]; - if (o.tryLoc <= this.prev && n.call(o, "finallyLoc") && this.prev < o.finallyLoc) { - var i = o; - break; - } - } - i && ("break" === t || "continue" === t) && i.tryLoc <= e && e <= i.finallyLoc && (i = null); - var a = i ? i.completion : {}; - return a.type = t, a.arg = e, i ? (this.method = "next", this.next = i.finallyLoc, y) : this.complete(a); - }, - complete: function (t, e) { - if ("throw" === t.type) throw t.arg; - return "break" === t.type || "continue" === t.type ? this.next = t.arg : "return" === t.type ? (this.rval = this.arg = t.arg, this.method = "return", this.next = "end") : "normal" === t.type && e && (this.next = e), y; - }, - finish: function (t) { - for (var e = this.tryEntries.length - 1; e >= 0; --e) { - var r = this.tryEntries[e]; - if (r.finallyLoc === t) return this.complete(r.completion, r.afterLoc), resetTryEntry(r), y; - } - }, - catch: function (t) { - for (var e = this.tryEntries.length - 1; e >= 0; --e) { - var r = this.tryEntries[e]; - if (r.tryLoc === t) { - var n = r.completion; - if ("throw" === n.type) { - var o = n.arg; - resetTryEntry(r); - } - return o; - } - } - throw new Error("illegal catch attempt"); - }, - delegateYield: function (e, r, n) { - return this.delegate = { - iterator: values(e), - resultName: r, - nextLoc: n - }, "next" === this.method && (this.arg = t), y; - } - }, e; - } - function _typeof(o) { - "@babel/helpers - typeof"; - - return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function (o) { - return typeof o; - } : function (o) { - return o && "function" == typeof Symbol && o.constructor === Symbol && o !== Symbol.prototype ? "symbol" : typeof o; - }, _typeof(o); - } - function _using(o, e, n) { - if (null == e) return e; - if ("object" != typeof e) throw new TypeError("using declarations can only be used with objects, null, or undefined."); - if (n) var r = e[Symbol.asyncDispose || Symbol.for("Symbol.asyncDispose")]; - if (null == r && (r = e[Symbol.dispose || Symbol.for("Symbol.dispose")]), "function" != typeof r) throw new TypeError("Property [Symbol.dispose] is not a function."); - return o.push({ - v: e, - d: r, - a: n - }), e; - } - function _wrapRegExp() { - _wrapRegExp = function (e, r) { - return new BabelRegExp(e, void 0, r); - }; - var e = RegExp.prototype, - r = new WeakMap(); - function BabelRegExp(e, t, p) { - var o = new RegExp(e, t); - return r.set(o, p || r.get(e)), _setPrototypeOf(o, BabelRegExp.prototype); - } - function buildGroups(e, t) { - var p = r.get(t); - return Object.keys(p).reduce(function (r, t) { - var o = p[t]; - if ("number" == typeof o) r[t] = e[o];else { - for (var i = 0; void 0 === e[o[i]] && i + 1 < o.length;) i++; - r[t] = e[o[i]]; - } - return r; - }, Object.create(null)); - } - return _inherits(BabelRegExp, RegExp), BabelRegExp.prototype.exec = function (r) { - var t = e.exec.call(this, r); - if (t) { - t.groups = buildGroups(t, this); - var p = t.indices; - p && (p.groups = buildGroups(p, this)); - } - return t; - }, BabelRegExp.prototype[Symbol.replace] = function (t, p) { - if ("string" == typeof p) { - var o = r.get(this); - return e[Symbol.replace].call(this, t, p.replace(/\$<([^>]+)>/g, function (e, r) { - var t = o[r]; - return "$" + (Array.isArray(t) ? t.join("$") : t); - })); - } - if ("function" == typeof p) { - var i = this; - return e[Symbol.replace].call(this, t, function () { - var e = arguments; - return "object" != typeof e[e.length - 1] && (e = [].slice.call(e)).push(buildGroups(e, i)), p.apply(this, e); - }); - } - return e[Symbol.replace].call(this, t, p); - }, _wrapRegExp.apply(this, arguments); - } - function _AwaitValue(value) { - this.wrapped = value; - } - function _wrapAsyncGenerator(fn) { - return function () { - return new _AsyncGenerator(fn.apply(this, arguments)); - }; - } - function asyncGeneratorStep(gen, resolve, reject, _next, _throw, key, arg) { - try { - var info = gen[key](arg); - var value = info.value; - } catch (error) { - reject(error); - return; - } - if (info.done) { - resolve(value); - } else { - Promise.resolve(value).then(_next, _throw); - } - } - function _asyncToGenerator(fn) { - return function () { - var self = this, - args = arguments; - return new Promise(function (resolve, reject) { - var gen = fn.apply(self, args); - function _next(value) { - asyncGeneratorStep(gen, resolve, reject, _next, _throw, "next", value); - } - function _throw(err) { - asyncGeneratorStep(gen, resolve, reject, _next, _throw, "throw", err); - } - _next(undefined); - }); - }; - } - function _classCallCheck(instance, Constructor) { - if (!(instance instanceof Constructor)) { - throw new TypeError("Cannot call a class as a function"); - } - } - function _defineProperties(target, props) { - for (var i = 0; i < props.length; i++) { - var descriptor = props[i]; - descriptor.enumerable = descriptor.enumerable || false; - descriptor.configurable = true; - if ("value" in descriptor) descriptor.writable = true; - Object.defineProperty(target, _toPropertyKey(descriptor.key), descriptor); - } - } - function _createClass(Constructor, protoProps, staticProps) { - if (protoProps) _defineProperties(Constructor.prototype, protoProps); - if (staticProps) _defineProperties(Constructor, staticProps); - Object.defineProperty(Constructor, "prototype", { - writable: false - }); - return Constructor; - } - function _defineEnumerableProperties(obj, descs) { - for (var key in descs) { - var desc = descs[key]; - desc.configurable = desc.enumerable = true; - if ("value" in desc) desc.writable = true; - Object.defineProperty(obj, key, desc); - } - if (Object.getOwnPropertySymbols) { - var objectSymbols = Object.getOwnPropertySymbols(descs); - for (var i = 0; i < objectSymbols.length; i++) { - var sym = objectSymbols[i]; - var desc = descs[sym]; - desc.configurable = desc.enumerable = true; - if ("value" in desc) desc.writable = true; - Object.defineProperty(obj, sym, desc); - } - } - return obj; - } - function _defaults(obj, defaults) { - var keys = Object.getOwnPropertyNames(defaults); - for (var i = 0; i < keys.length; i++) { - var key = keys[i]; - var value = Object.getOwnPropertyDescriptor(defaults, key); - if (value && value.configurable && obj[key] === undefined) { - Object.defineProperty(obj, key, value); - } - } - return obj; - } - function _defineProperty(obj, key, value) { - key = _toPropertyKey(key); - if (key in obj) { - Object.defineProperty(obj, key, { - value: value, - enumerable: true, - configurable: true, - writable: true - }); - } else { - obj[key] = value; - } - return obj; - } - function _extends() { - _extends = Object.assign ? Object.assign.bind() : function (target) { - for (var i = 1; i < arguments.length; i++) { - var source = arguments[i]; - for (var key in source) { - if (Object.prototype.hasOwnProperty.call(source, key)) { - target[key] = source[key]; - } - } - } - return target; - }; - return _extends.apply(this, arguments); - } - function _objectSpread(target) { - for (var i = 1; i < arguments.length; i++) { - var source = arguments[i] != null ? Object(arguments[i]) : {}; - var ownKeys = Object.keys(source); - if (typeof Object.getOwnPropertySymbols === 'function') { - ownKeys.push.apply(ownKeys, Object.getOwnPropertySymbols(source).filter(function (sym) { - return Object.getOwnPropertyDescriptor(source, sym).enumerable; - })); - } - ownKeys.forEach(function (key) { - _defineProperty(target, key, source[key]); - }); - } - return target; - } - function _inherits(subClass, superClass) { - if (typeof superClass !== "function" && superClass !== null) { - throw new TypeError("Super expression must either be null or a function"); - } - subClass.prototype = Object.create(superClass && superClass.prototype, { - constructor: { - value: subClass, - writable: true, - configurable: true - } - }); - Object.defineProperty(subClass, "prototype", { - writable: false - }); - if (superClass) _setPrototypeOf(subClass, superClass); - } - function _inheritsLoose(subClass, superClass) { - subClass.prototype = Object.create(superClass.prototype); - subClass.prototype.constructor = subClass; - _setPrototypeOf(subClass, superClass); - } - function _getPrototypeOf(o) { - _getPrototypeOf = Object.setPrototypeOf ? Object.getPrototypeOf.bind() : function _getPrototypeOf(o) { - return o.__proto__ || Object.getPrototypeOf(o); - }; - return _getPrototypeOf(o); - } - function _setPrototypeOf(o, p) { - _setPrototypeOf = Object.setPrototypeOf ? Object.setPrototypeOf.bind() : function _setPrototypeOf(o, p) { - o.__proto__ = p; - return o; - }; - return _setPrototypeOf(o, p); - } - function _isNativeReflectConstruct() { - if (typeof Reflect === "undefined" || !Reflect.construct) return false; - if (Reflect.construct.sham) return false; - if (typeof Proxy === "function") return true; - try { - Boolean.prototype.valueOf.call(Reflect.construct(Boolean, [], function () {})); - return true; - } catch (e) { - return false; - } - } - function _construct(Parent, args, Class) { - if (_isNativeReflectConstruct()) { - _construct = Reflect.construct.bind(); - } else { - _construct = function _construct(Parent, args, Class) { - var a = [null]; - a.push.apply(a, args); - var Constructor = Function.bind.apply(Parent, a); - var instance = new Constructor(); - if (Class) _setPrototypeOf(instance, Class.prototype); - return instance; - }; - } - return _construct.apply(null, arguments); - } - function _isNativeFunction(fn) { - return Function.toString.call(fn).indexOf("[native code]") !== -1; - } - function _wrapNativeSuper(Class) { - var _cache = typeof Map === "function" ? new Map() : undefined; - _wrapNativeSuper = function _wrapNativeSuper(Class) { - if (Class === null || !_isNativeFunction(Class)) return Class; - if (typeof Class !== "function") { - throw new TypeError("Super expression must either be null or a function"); - } - if (typeof _cache !== "undefined") { - if (_cache.has(Class)) return _cache.get(Class); - _cache.set(Class, Wrapper); - } - function Wrapper() { - return _construct(Class, arguments, _getPrototypeOf(this).constructor); - } - Wrapper.prototype = Object.create(Class.prototype, { - constructor: { - value: Wrapper, - enumerable: false, - writable: true, - configurable: true - } - }); - return _setPrototypeOf(Wrapper, Class); - }; - return _wrapNativeSuper(Class); - } - function _instanceof(left, right) { - if (right != null && typeof Symbol !== "undefined" && right[Symbol.hasInstance]) { - return !!right[Symbol.hasInstance](left); - } else { - return left instanceof right; - } - } - function _interopRequireDefault(obj) { - return obj && obj.__esModule ? obj : { - default: obj - }; - } - function _getRequireWildcardCache(nodeInterop) { - if (typeof WeakMap !== "function") return null; - var cacheBabelInterop = new WeakMap(); - var cacheNodeInterop = new WeakMap(); - return (_getRequireWildcardCache = function (nodeInterop) { - return nodeInterop ? cacheNodeInterop : cacheBabelInterop; - })(nodeInterop); - } - function _interopRequireWildcard(obj, nodeInterop) { - if (!nodeInterop && obj && obj.__esModule) { - return obj; - } - if (obj === null || typeof obj !== "object" && typeof obj !== "function") { - return { - default: obj - }; - } - var cache = _getRequireWildcardCache(nodeInterop); - if (cache && cache.has(obj)) { - return cache.get(obj); - } - var newObj = {}; - var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; - for (var key in obj) { - if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { - var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; - if (desc && (desc.get || desc.set)) { - Object.defineProperty(newObj, key, desc); - } else { - newObj[key] = obj[key]; - } - } - } - newObj.default = obj; - if (cache) { - cache.set(obj, newObj); - } - return newObj; - } - function _newArrowCheck(innerThis, boundThis) { - if (innerThis !== boundThis) { - throw new TypeError("Cannot instantiate an arrow function"); - } - } - function _objectDestructuringEmpty(obj) { - if (obj == null) throw new TypeError("Cannot destructure " + obj); - } - function _objectWithoutPropertiesLoose(source, excluded) { - if (source == null) return {}; - var target = {}; - var sourceKeys = Object.keys(source); - var key, i; - for (i = 0; i < sourceKeys.length; i++) { - key = sourceKeys[i]; - if (excluded.indexOf(key) >= 0) continue; - target[key] = source[key]; - } - return target; - } - function _objectWithoutProperties(source, excluded) { - if (source == null) return {}; - var target = _objectWithoutPropertiesLoose(source, excluded); - var key, i; - if (Object.getOwnPropertySymbols) { - var sourceSymbolKeys = Object.getOwnPropertySymbols(source); - for (i = 0; i < sourceSymbolKeys.length; i++) { - key = sourceSymbolKeys[i]; - if (excluded.indexOf(key) >= 0) continue; - if (!Object.prototype.propertyIsEnumerable.call(source, key)) continue; - target[key] = source[key]; - } - } - return target; - } - function _assertThisInitialized(self) { - if (self === void 0) { - throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); - } - return self; - } - function _possibleConstructorReturn(self, call) { - if (call && (typeof call === "object" || typeof call === "function")) { - return call; - } else if (call !== void 0) { - throw new TypeError("Derived constructors may only return object or undefined"); - } - return _assertThisInitialized(self); - } - function _createSuper(Derived) { - var hasNativeReflectConstruct = _isNativeReflectConstruct(); - return function _createSuperInternal() { - var Super = _getPrototypeOf(Derived), - result; - if (hasNativeReflectConstruct) { - var NewTarget = _getPrototypeOf(this).constructor; - result = Reflect.construct(Super, arguments, NewTarget); - } else { - result = Super.apply(this, arguments); - } - return _possibleConstructorReturn(this, result); - }; - } - function _superPropBase(object, property) { - while (!Object.prototype.hasOwnProperty.call(object, property)) { - object = _getPrototypeOf(object); - if (object === null) break; - } - return object; - } - function _get() { - if (typeof Reflect !== "undefined" && Reflect.get) { - _get = Reflect.get.bind(); - } else { - _get = function _get(target, property, receiver) { - var base = _superPropBase(target, property); - if (!base) return; - var desc = Object.getOwnPropertyDescriptor(base, property); - if (desc.get) { - return desc.get.call(arguments.length < 3 ? target : receiver); - } - return desc.value; - }; - } - return _get.apply(this, arguments); - } - function set(target, property, value, receiver) { - if (typeof Reflect !== "undefined" && Reflect.set) { - set = Reflect.set; - } else { - set = function set(target, property, value, receiver) { - var base = _superPropBase(target, property); - var desc; - if (base) { - desc = Object.getOwnPropertyDescriptor(base, property); - if (desc.set) { - desc.set.call(receiver, value); - return true; - } else if (!desc.writable) { - return false; - } - } - desc = Object.getOwnPropertyDescriptor(receiver, property); - if (desc) { - if (!desc.writable) { - return false; - } - desc.value = value; - Object.defineProperty(receiver, property, desc); - } else { - _defineProperty(receiver, property, value); - } - return true; - }; - } - return set(target, property, value, receiver); - } - function _set(target, property, value, receiver, isStrict) { - var s = set(target, property, value, receiver || target); - if (!s && isStrict) { - throw new TypeError('failed to set property'); - } - return value; - } - function _taggedTemplateLiteral(strings, raw) { - if (!raw) { - raw = strings.slice(0); - } - return Object.freeze(Object.defineProperties(strings, { - raw: { - value: Object.freeze(raw) - } - })); - } - function _taggedTemplateLiteralLoose(strings, raw) { - if (!raw) { - raw = strings.slice(0); - } - strings.raw = raw; - return strings; - } - function _readOnlyError(name) { - throw new TypeError("\"" + name + "\" is read-only"); - } - function _writeOnlyError(name) { - throw new TypeError("\"" + name + "\" is write-only"); - } - function _classNameTDZError(name) { - throw new ReferenceError("Class \"" + name + "\" cannot be referenced in computed property keys."); - } - function _temporalUndefined() {} - function _tdz(name) { - throw new ReferenceError(name + " is not defined - temporal dead zone"); - } - function _temporalRef(val, name) { - return val === _temporalUndefined ? _tdz(name) : val; - } - function _slicedToArray(arr, i) { - return _arrayWithHoles(arr) || _iterableToArrayLimit(arr, i) || _unsupportedIterableToArray(arr, i) || _nonIterableRest(); - } - function _slicedToArrayLoose(arr, i) { - return _arrayWithHoles(arr) || _iterableToArrayLimitLoose(arr, i) || _unsupportedIterableToArray(arr, i) || _nonIterableRest(); - } - function _toArray(arr) { - return _arrayWithHoles(arr) || _iterableToArray(arr) || _unsupportedIterableToArray(arr) || _nonIterableRest(); - } - function _toConsumableArray(arr) { - return _arrayWithoutHoles(arr) || _iterableToArray(arr) || _unsupportedIterableToArray(arr) || _nonIterableSpread(); - } - function _arrayWithoutHoles(arr) { - if (Array.isArray(arr)) return _arrayLikeToArray(arr); - } - function _arrayWithHoles(arr) { - if (Array.isArray(arr)) return arr; - } - function _maybeArrayLike(next, arr, i) { - if (arr && !Array.isArray(arr) && typeof arr.length === "number") { - var len = arr.length; - return _arrayLikeToArray(arr, i !== void 0 && i < len ? i : len); - } - return next(arr, i); - } - function _iterableToArray(iter) { - if (typeof Symbol !== "undefined" && iter[Symbol.iterator] != null || iter["@@iterator"] != null) return Array.from(iter); - } - function _unsupportedIterableToArray(o, minLen) { - if (!o) return; - if (typeof o === "string") return _arrayLikeToArray(o, minLen); - var n = Object.prototype.toString.call(o).slice(8, -1); - if (n === "Object" && o.constructor) n = o.constructor.name; - if (n === "Map" || n === "Set") return Array.from(o); - if (n === "Arguments" || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)) return _arrayLikeToArray(o, minLen); - } - function _arrayLikeToArray(arr, len) { - if (len == null || len > arr.length) len = arr.length; - for (var i = 0, arr2 = new Array(len); i < len; i++) arr2[i] = arr[i]; - return arr2; - } - function _nonIterableSpread() { - throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); - } - function _nonIterableRest() { - throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); - } - function _createForOfIteratorHelper(o, allowArrayLike) { - var it = typeof Symbol !== "undefined" && o[Symbol.iterator] || o["@@iterator"]; - if (!it) { - if (Array.isArray(o) || (it = _unsupportedIterableToArray(o)) || allowArrayLike && o && typeof o.length === "number") { - if (it) o = it; - var i = 0; - var F = function () {}; - return { - s: F, - n: function () { - if (i >= o.length) return { - done: true - }; - return { - done: false, - value: o[i++] - }; - }, - e: function (e) { - throw e; - }, - f: F - }; - } - throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); - } - var normalCompletion = true, - didErr = false, - err; - return { - s: function () { - it = it.call(o); - }, - n: function () { - var step = it.next(); - normalCompletion = step.done; - return step; - }, - e: function (e) { - didErr = true; - err = e; - }, - f: function () { - try { - if (!normalCompletion && it.return != null) it.return(); - } finally { - if (didErr) throw err; - } - } - }; - } - function _createForOfIteratorHelperLoose(o, allowArrayLike) { - var it = typeof Symbol !== "undefined" && o[Symbol.iterator] || o["@@iterator"]; - if (it) return (it = it.call(o)).next.bind(it); - if (Array.isArray(o) || (it = _unsupportedIterableToArray(o)) || allowArrayLike && o && typeof o.length === "number") { - if (it) o = it; - var i = 0; - return function () { - if (i >= o.length) return { - done: true - }; - return { - done: false, - value: o[i++] - }; - }; - } - throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); - } - function _skipFirstGeneratorNext(fn) { - return function () { - var it = fn.apply(this, arguments); - it.next(); - return it; - }; - } - function _toPrimitive(input, hint) { - if (typeof input !== "object" || input === null) return input; - var prim = input[Symbol.toPrimitive]; - if (prim !== undefined) { - var res = prim.call(input, hint || "default"); - if (typeof res !== "object") return res; - throw new TypeError("@@toPrimitive must return a primitive value."); - } - return (hint === "string" ? String : Number)(input); - } - function _toPropertyKey(arg) { - var key = _toPrimitive(arg, "string"); - return typeof key === "symbol" ? key : String(key); - } - function _initializerWarningHelper(descriptor, context) { - throw new Error('Decorating class property failed. Please ensure that ' + 'transform-class-properties is enabled and runs after the decorators transform.'); - } - function _initializerDefineProperty(target, property, descriptor, context) { - if (!descriptor) return; - Object.defineProperty(target, property, { - enumerable: descriptor.enumerable, - configurable: descriptor.configurable, - writable: descriptor.writable, - value: descriptor.initializer ? descriptor.initializer.call(context) : void 0 - }); - } - function _applyDecoratedDescriptor(target, property, decorators, descriptor, context) { - var desc = {}; - Object.keys(descriptor).forEach(function (key) { - desc[key] = descriptor[key]; - }); - desc.enumerable = !!desc.enumerable; - desc.configurable = !!desc.configurable; - if ('value' in desc || desc.initializer) { - desc.writable = true; - } - desc = decorators.slice().reverse().reduce(function (desc, decorator) { - return decorator(target, property, desc) || desc; - }, desc); - if (context && desc.initializer !== void 0) { - desc.value = desc.initializer ? desc.initializer.call(context) : void 0; - desc.initializer = undefined; - } - if (desc.initializer === void 0) { - Object.defineProperty(target, property, desc); - desc = null; - } - return desc; - } - var id$1 = 0; - function _classPrivateFieldLooseKey(name) { - return "__private_" + id$1++ + "_" + name; - } - function _classPrivateFieldLooseBase(receiver, privateKey) { - if (!Object.prototype.hasOwnProperty.call(receiver, privateKey)) { - throw new TypeError("attempted to use private field on non-instance"); - } - return receiver; - } - function _classPrivateFieldGet(receiver, privateMap) { - var descriptor = _classExtractFieldDescriptor(receiver, privateMap, "get"); - return _classApplyDescriptorGet(receiver, descriptor); - } - function _classPrivateFieldSet(receiver, privateMap, value) { - var descriptor = _classExtractFieldDescriptor(receiver, privateMap, "set"); - _classApplyDescriptorSet(receiver, descriptor, value); - return value; - } - function _classPrivateFieldDestructureSet(receiver, privateMap) { - var descriptor = _classExtractFieldDescriptor(receiver, privateMap, "set"); - return _classApplyDescriptorDestructureSet(receiver, descriptor); - } - function _classExtractFieldDescriptor(receiver, privateMap, action) { - if (!privateMap.has(receiver)) { - throw new TypeError("attempted to " + action + " private field on non-instance"); - } - return privateMap.get(receiver); - } - function _classStaticPrivateFieldSpecGet(receiver, classConstructor, descriptor) { - _classCheckPrivateStaticAccess(receiver, classConstructor); - _classCheckPrivateStaticFieldDescriptor(descriptor, "get"); - return _classApplyDescriptorGet(receiver, descriptor); - } - function _classStaticPrivateFieldSpecSet(receiver, classConstructor, descriptor, value) { - _classCheckPrivateStaticAccess(receiver, classConstructor); - _classCheckPrivateStaticFieldDescriptor(descriptor, "set"); - _classApplyDescriptorSet(receiver, descriptor, value); - return value; - } - function _classStaticPrivateMethodGet(receiver, classConstructor, method) { - _classCheckPrivateStaticAccess(receiver, classConstructor); - return method; - } - function _classStaticPrivateMethodSet() { - throw new TypeError("attempted to set read only static private field"); - } - function _classApplyDescriptorGet(receiver, descriptor) { - if (descriptor.get) { - return descriptor.get.call(receiver); - } - return descriptor.value; - } - function _classApplyDescriptorSet(receiver, descriptor, value) { - if (descriptor.set) { - descriptor.set.call(receiver, value); - } else { - if (!descriptor.writable) { - throw new TypeError("attempted to set read only private field"); - } - descriptor.value = value; - } - } - function _classApplyDescriptorDestructureSet(receiver, descriptor) { - if (descriptor.set) { - if (!("__destrObj" in descriptor)) { - descriptor.__destrObj = { - set value(v) { - descriptor.set.call(receiver, v); - } - }; - } - return descriptor.__destrObj; - } else { - if (!descriptor.writable) { - throw new TypeError("attempted to set read only private field"); - } - return descriptor; - } - } - function _classStaticPrivateFieldDestructureSet(receiver, classConstructor, descriptor) { - _classCheckPrivateStaticAccess(receiver, classConstructor); - _classCheckPrivateStaticFieldDescriptor(descriptor, "set"); - return _classApplyDescriptorDestructureSet(receiver, descriptor); - } - function _classCheckPrivateStaticAccess(receiver, classConstructor) { - if (receiver !== classConstructor) { - throw new TypeError("Private static access of wrong provenance"); - } - } - function _classCheckPrivateStaticFieldDescriptor(descriptor, action) { - if (descriptor === undefined) { - throw new TypeError("attempted to " + action + " private static field before its declaration"); - } - } - function _decorate(decorators, factory, superClass, mixins) { - var api = _getDecoratorsApi(); - if (mixins) { - for (var i = 0; i < mixins.length; i++) { - api = mixins[i](api); - } - } - var r = factory(function initialize(O) { - api.initializeInstanceElements(O, decorated.elements); - }, superClass); - var decorated = api.decorateClass(_coalesceClassElements(r.d.map(_createElementDescriptor)), decorators); - api.initializeClassElements(r.F, decorated.elements); - return api.runClassFinishers(r.F, decorated.finishers); - } - function _getDecoratorsApi() { - _getDecoratorsApi = function () { - return api; - }; - var api = { - elementsDefinitionOrder: [["method"], ["field"]], - initializeInstanceElements: function (O, elements) { - ["method", "field"].forEach(function (kind) { - elements.forEach(function (element) { - if (element.kind === kind && element.placement === "own") { - this.defineClassElement(O, element); - } - }, this); - }, this); - }, - initializeClassElements: function (F, elements) { - var proto = F.prototype; - ["method", "field"].forEach(function (kind) { - elements.forEach(function (element) { - var placement = element.placement; - if (element.kind === kind && (placement === "static" || placement === "prototype")) { - var receiver = placement === "static" ? F : proto; - this.defineClassElement(receiver, element); - } - }, this); - }, this); - }, - defineClassElement: function (receiver, element) { - var descriptor = element.descriptor; - if (element.kind === "field") { - var initializer = element.initializer; - descriptor = { - enumerable: descriptor.enumerable, - writable: descriptor.writable, - configurable: descriptor.configurable, - value: initializer === void 0 ? void 0 : initializer.call(receiver) - }; - } - Object.defineProperty(receiver, element.key, descriptor); - }, - decorateClass: function (elements, decorators) { - var newElements = []; - var finishers = []; - var placements = { - static: [], - prototype: [], - own: [] - }; - elements.forEach(function (element) { - this.addElementPlacement(element, placements); - }, this); - elements.forEach(function (element) { - if (!_hasDecorators(element)) return newElements.push(element); - var elementFinishersExtras = this.decorateElement(element, placements); - newElements.push(elementFinishersExtras.element); - newElements.push.apply(newElements, elementFinishersExtras.extras); - finishers.push.apply(finishers, elementFinishersExtras.finishers); - }, this); - if (!decorators) { - return { - elements: newElements, - finishers: finishers - }; - } - var result = this.decorateConstructor(newElements, decorators); - finishers.push.apply(finishers, result.finishers); - result.finishers = finishers; - return result; - }, - addElementPlacement: function (element, placements, silent) { - var keys = placements[element.placement]; - if (!silent && keys.indexOf(element.key) !== -1) { - throw new TypeError("Duplicated element (" + element.key + ")"); - } - keys.push(element.key); - }, - decorateElement: function (element, placements) { - var extras = []; - var finishers = []; - for (var decorators = element.decorators, i = decorators.length - 1; i >= 0; i--) { - var keys = placements[element.placement]; - keys.splice(keys.indexOf(element.key), 1); - var elementObject = this.fromElementDescriptor(element); - var elementFinisherExtras = this.toElementFinisherExtras((0, decorators[i])(elementObject) || elementObject); - element = elementFinisherExtras.element; - this.addElementPlacement(element, placements); - if (elementFinisherExtras.finisher) { - finishers.push(elementFinisherExtras.finisher); - } - var newExtras = elementFinisherExtras.extras; - if (newExtras) { - for (var j = 0; j < newExtras.length; j++) { - this.addElementPlacement(newExtras[j], placements); - } - extras.push.apply(extras, newExtras); - } - } - return { - element: element, - finishers: finishers, - extras: extras - }; - }, - decorateConstructor: function (elements, decorators) { - var finishers = []; - for (var i = decorators.length - 1; i >= 0; i--) { - var obj = this.fromClassDescriptor(elements); - var elementsAndFinisher = this.toClassDescriptor((0, decorators[i])(obj) || obj); - if (elementsAndFinisher.finisher !== undefined) { - finishers.push(elementsAndFinisher.finisher); - } - if (elementsAndFinisher.elements !== undefined) { - elements = elementsAndFinisher.elements; - for (var j = 0; j < elements.length - 1; j++) { - for (var k = j + 1; k < elements.length; k++) { - if (elements[j].key === elements[k].key && elements[j].placement === elements[k].placement) { - throw new TypeError("Duplicated element (" + elements[j].key + ")"); - } - } - } - } - } - return { - elements: elements, - finishers: finishers - }; - }, - fromElementDescriptor: function (element) { - var obj = { - kind: element.kind, - key: element.key, - placement: element.placement, - descriptor: element.descriptor - }; - var desc = { - value: "Descriptor", - configurable: true - }; - Object.defineProperty(obj, Symbol.toStringTag, desc); - if (element.kind === "field") obj.initializer = element.initializer; - return obj; - }, - toElementDescriptors: function (elementObjects) { - if (elementObjects === undefined) return; - return _toArray(elementObjects).map(function (elementObject) { - var element = this.toElementDescriptor(elementObject); - this.disallowProperty(elementObject, "finisher", "An element descriptor"); - this.disallowProperty(elementObject, "extras", "An element descriptor"); - return element; - }, this); - }, - toElementDescriptor: function (elementObject) { - var kind = String(elementObject.kind); - if (kind !== "method" && kind !== "field") { - throw new TypeError('An element descriptor\'s .kind property must be either "method" or' + ' "field", but a decorator created an element descriptor with' + ' .kind "' + kind + '"'); - } - var key = _toPropertyKey(elementObject.key); - var placement = String(elementObject.placement); - if (placement !== "static" && placement !== "prototype" && placement !== "own") { - throw new TypeError('An element descriptor\'s .placement property must be one of "static",' + ' "prototype" or "own", but a decorator created an element descriptor' + ' with .placement "' + placement + '"'); - } - var descriptor = elementObject.descriptor; - this.disallowProperty(elementObject, "elements", "An element descriptor"); - var element = { - kind: kind, - key: key, - placement: placement, - descriptor: Object.assign({}, descriptor) - }; - if (kind !== "field") { - this.disallowProperty(elementObject, "initializer", "A method descriptor"); - } else { - this.disallowProperty(descriptor, "get", "The property descriptor of a field descriptor"); - this.disallowProperty(descriptor, "set", "The property descriptor of a field descriptor"); - this.disallowProperty(descriptor, "value", "The property descriptor of a field descriptor"); - element.initializer = elementObject.initializer; - } - return element; - }, - toElementFinisherExtras: function (elementObject) { - var element = this.toElementDescriptor(elementObject); - var finisher = _optionalCallableProperty(elementObject, "finisher"); - var extras = this.toElementDescriptors(elementObject.extras); - return { - element: element, - finisher: finisher, - extras: extras - }; - }, - fromClassDescriptor: function (elements) { - var obj = { - kind: "class", - elements: elements.map(this.fromElementDescriptor, this) - }; - var desc = { - value: "Descriptor", - configurable: true - }; - Object.defineProperty(obj, Symbol.toStringTag, desc); - return obj; - }, - toClassDescriptor: function (obj) { - var kind = String(obj.kind); - if (kind !== "class") { - throw new TypeError('A class descriptor\'s .kind property must be "class", but a decorator' + ' created a class descriptor with .kind "' + kind + '"'); - } - this.disallowProperty(obj, "key", "A class descriptor"); - this.disallowProperty(obj, "placement", "A class descriptor"); - this.disallowProperty(obj, "descriptor", "A class descriptor"); - this.disallowProperty(obj, "initializer", "A class descriptor"); - this.disallowProperty(obj, "extras", "A class descriptor"); - var finisher = _optionalCallableProperty(obj, "finisher"); - var elements = this.toElementDescriptors(obj.elements); - return { - elements: elements, - finisher: finisher - }; - }, - runClassFinishers: function (constructor, finishers) { - for (var i = 0; i < finishers.length; i++) { - var newConstructor = (0, finishers[i])(constructor); - if (newConstructor !== undefined) { - if (typeof newConstructor !== "function") { - throw new TypeError("Finishers must return a constructor."); - } - constructor = newConstructor; - } - } - return constructor; - }, - disallowProperty: function (obj, name, objectType) { - if (obj[name] !== undefined) { - throw new TypeError(objectType + " can't have a ." + name + " property."); - } - } - }; - return api; - } - function _createElementDescriptor(def) { - var key = _toPropertyKey(def.key); - var descriptor; - if (def.kind === "method") { - descriptor = { - value: def.value, - writable: true, - configurable: true, - enumerable: false - }; - } else if (def.kind === "get") { - descriptor = { - get: def.value, - configurable: true, - enumerable: false - }; - } else if (def.kind === "set") { - descriptor = { - set: def.value, - configurable: true, - enumerable: false - }; - } else if (def.kind === "field") { - descriptor = { - configurable: true, - writable: true, - enumerable: true - }; - } - var element = { - kind: def.kind === "field" ? "field" : "method", - key: key, - placement: def.static ? "static" : def.kind === "field" ? "own" : "prototype", - descriptor: descriptor - }; - if (def.decorators) element.decorators = def.decorators; - if (def.kind === "field") element.initializer = def.value; - return element; - } - function _coalesceGetterSetter(element, other) { - if (element.descriptor.get !== undefined) { - other.descriptor.get = element.descriptor.get; - } else { - other.descriptor.set = element.descriptor.set; - } - } - function _coalesceClassElements(elements) { - var newElements = []; - var isSameElement = function (other) { - return other.kind === "method" && other.key === element.key && other.placement === element.placement; - }; - for (var i = 0; i < elements.length; i++) { - var element = elements[i]; - var other; - if (element.kind === "method" && (other = newElements.find(isSameElement))) { - if (_isDataDescriptor(element.descriptor) || _isDataDescriptor(other.descriptor)) { - if (_hasDecorators(element) || _hasDecorators(other)) { - throw new ReferenceError("Duplicated methods (" + element.key + ") can't be decorated."); - } - other.descriptor = element.descriptor; - } else { - if (_hasDecorators(element)) { - if (_hasDecorators(other)) { - throw new ReferenceError("Decorators can't be placed on different accessors with for " + "the same property (" + element.key + ")."); - } - other.decorators = element.decorators; - } - _coalesceGetterSetter(element, other); - } - } else { - newElements.push(element); - } - } - return newElements; - } - function _hasDecorators(element) { - return element.decorators && element.decorators.length; - } - function _isDataDescriptor(desc) { - return desc !== undefined && !(desc.value === undefined && desc.writable === undefined); - } - function _optionalCallableProperty(obj, name) { - var value = obj[name]; - if (value !== undefined && typeof value !== "function") { - throw new TypeError("Expected '" + name + "' to be a function"); - } - return value; - } - function _classPrivateMethodGet(receiver, privateSet, fn) { - if (!privateSet.has(receiver)) { - throw new TypeError("attempted to get private field on non-instance"); - } - return fn; - } - function _checkPrivateRedeclaration(obj, privateCollection) { - if (privateCollection.has(obj)) { - throw new TypeError("Cannot initialize the same private elements twice on an object"); - } - } - function _classPrivateFieldInitSpec(obj, privateMap, value) { - _checkPrivateRedeclaration(obj, privateMap); - privateMap.set(obj, value); - } - function _classPrivateMethodInitSpec(obj, privateSet) { - _checkPrivateRedeclaration(obj, privateSet); - privateSet.add(obj); - } - function _classPrivateMethodSet() { - throw new TypeError("attempted to reassign private method"); - } - function _identity(x) { - return x; - } - function _nullishReceiverError(r) { - throw new TypeError("Cannot set property of null or undefined."); - } - - class Piece extends TrixObject { - static registerType(type, constructor) { - constructor.type = type; - this.types[type] = constructor; - } - static fromJSON(pieceJSON) { - const constructor = this.types[pieceJSON.type]; - if (constructor) { - return constructor.fromJSON(pieceJSON); - } - } - constructor(value) { - let attributes = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; - super(...arguments); - this.attributes = Hash.box(attributes); - } - copyWithAttributes(attributes) { - return new this.constructor(this.getValue(), attributes); - } - copyWithAdditionalAttributes(attributes) { - return this.copyWithAttributes(this.attributes.merge(attributes)); - } - copyWithoutAttribute(attribute) { - return this.copyWithAttributes(this.attributes.remove(attribute)); - } - copy() { - return this.copyWithAttributes(this.attributes); - } - getAttribute(attribute) { - return this.attributes.get(attribute); - } - getAttributesHash() { - return this.attributes; - } - getAttributes() { - return this.attributes.toObject(); - } - hasAttribute(attribute) { - return this.attributes.has(attribute); - } - hasSameStringValueAsPiece(piece) { - return piece && this.toString() === piece.toString(); - } - hasSameAttributesAsPiece(piece) { - return piece && (this.attributes === piece.attributes || this.attributes.isEqualTo(piece.attributes)); - } - isBlockBreak() { - return false; - } - isEqualTo(piece) { - return super.isEqualTo(...arguments) || this.hasSameConstructorAs(piece) && this.hasSameStringValueAsPiece(piece) && this.hasSameAttributesAsPiece(piece); - } - isEmpty() { - return this.length === 0; - } - isSerializable() { - return true; - } - toJSON() { - return { - type: this.constructor.type, - attributes: this.getAttributes() - }; - } - contentsForInspection() { - return { - type: this.constructor.type, - attributes: this.attributes.inspect() - }; - } - - // Grouping - - canBeGrouped() { - return this.hasAttribute("href"); - } - canBeGroupedWith(piece) { - return this.getAttribute("href") === piece.getAttribute("href"); - } - - // Splittable - - getLength() { - return this.length; - } - canBeConsolidatedWith(piece) { - return false; - } - } - _defineProperty(Piece, "types", {}); - - class ImagePreloadOperation extends Operation { - constructor(url) { - super(...arguments); - this.url = url; - } - perform(callback) { - const image = new Image(); - image.onload = () => { - image.width = this.width = image.naturalWidth; - image.height = this.height = image.naturalHeight; - return callback(true, image); - }; - image.onerror = () => callback(false); - image.src = this.url; - } - } - - class Attachment extends TrixObject { - static attachmentForFile(file) { - const attributes = this.attributesForFile(file); - const attachment = new this(attributes); - attachment.setFile(file); - return attachment; - } - static attributesForFile(file) { - return new Hash({ - filename: file.name, - filesize: file.size, - contentType: file.type - }); - } - static fromJSON(attachmentJSON) { - return new this(attachmentJSON); - } - constructor() { - let attributes = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; - super(attributes); - this.releaseFile = this.releaseFile.bind(this); - this.attributes = Hash.box(attributes); - this.didChangeAttributes(); - } - getAttribute(attribute) { - return this.attributes.get(attribute); - } - hasAttribute(attribute) { - return this.attributes.has(attribute); - } - getAttributes() { - return this.attributes.toObject(); - } - setAttributes() { - let attributes = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; - const newAttributes = this.attributes.merge(attributes); - if (!this.attributes.isEqualTo(newAttributes)) { - var _this$previewDelegate, _this$previewDelegate2, _this$delegate, _this$delegate$attach; - this.attributes = newAttributes; - this.didChangeAttributes(); - (_this$previewDelegate = this.previewDelegate) === null || _this$previewDelegate === void 0 || (_this$previewDelegate2 = _this$previewDelegate.attachmentDidChangeAttributes) === null || _this$previewDelegate2 === void 0 || _this$previewDelegate2.call(_this$previewDelegate, this); - return (_this$delegate = this.delegate) === null || _this$delegate === void 0 || (_this$delegate$attach = _this$delegate.attachmentDidChangeAttributes) === null || _this$delegate$attach === void 0 ? void 0 : _this$delegate$attach.call(_this$delegate, this); - } - } - didChangeAttributes() { - if (this.isPreviewable()) { - return this.preloadURL(); - } - } - isPending() { - return this.file != null && !(this.getURL() || this.getHref()); - } - isPreviewable() { - if (this.attributes.has("previewable")) { - return this.attributes.get("previewable"); - } else { - return Attachment.previewablePattern.test(this.getContentType()); - } - } - getType() { - if (this.hasContent()) { - return "content"; - } else if (this.isPreviewable()) { - return "preview"; - } else { - return "file"; - } - } - getURL() { - return this.attributes.get("url"); - } - getHref() { - return this.attributes.get("href"); - } - getFilename() { - return this.attributes.get("filename") || ""; - } - getFilesize() { - return this.attributes.get("filesize"); - } - getFormattedFilesize() { - const filesize = this.attributes.get("filesize"); - if (typeof filesize === "number") { - return file_size_formatting.formatter(filesize); - } else { - return ""; - } - } - getExtension() { - var _this$getFilename$mat; - return (_this$getFilename$mat = this.getFilename().match(/\.(\w+)$/)) === null || _this$getFilename$mat === void 0 ? void 0 : _this$getFilename$mat[1].toLowerCase(); - } - getContentType() { - return this.attributes.get("contentType"); - } - hasContent() { - return this.attributes.has("content"); - } - getContent() { - return this.attributes.get("content"); - } - getWidth() { - return this.attributes.get("width"); - } - getHeight() { - return this.attributes.get("height"); - } - getFile() { - return this.file; - } - setFile(file) { - this.file = file; - if (this.isPreviewable()) { - return this.preloadFile(); - } - } - releaseFile() { - this.releasePreloadedFile(); - this.file = null; - } - getUploadProgress() { - return this.uploadProgress != null ? this.uploadProgress : 0; - } - setUploadProgress(value) { - if (this.uploadProgress !== value) { - var _this$uploadProgressD, _this$uploadProgressD2; - this.uploadProgress = value; - return (_this$uploadProgressD = this.uploadProgressDelegate) === null || _this$uploadProgressD === void 0 || (_this$uploadProgressD2 = _this$uploadProgressD.attachmentDidChangeUploadProgress) === null || _this$uploadProgressD2 === void 0 ? void 0 : _this$uploadProgressD2.call(_this$uploadProgressD, this); - } - } - toJSON() { - return this.getAttributes(); - } - getCacheKey() { - return [super.getCacheKey(...arguments), this.attributes.getCacheKey(), this.getPreviewURL()].join("/"); - } - - // Previewable - - getPreviewURL() { - return this.previewURL || this.preloadingURL; - } - setPreviewURL(url) { - if (url !== this.getPreviewURL()) { - var _this$previewDelegate3, _this$previewDelegate4, _this$delegate2, _this$delegate2$attac; - this.previewURL = url; - (_this$previewDelegate3 = this.previewDelegate) === null || _this$previewDelegate3 === void 0 || (_this$previewDelegate4 = _this$previewDelegate3.attachmentDidChangeAttributes) === null || _this$previewDelegate4 === void 0 || _this$previewDelegate4.call(_this$previewDelegate3, this); - return (_this$delegate2 = this.delegate) === null || _this$delegate2 === void 0 || (_this$delegate2$attac = _this$delegate2.attachmentDidChangePreviewURL) === null || _this$delegate2$attac === void 0 ? void 0 : _this$delegate2$attac.call(_this$delegate2, this); - } - } - preloadURL() { - return this.preload(this.getURL(), this.releaseFile); - } - preloadFile() { - if (this.file) { - this.fileObjectURL = URL.createObjectURL(this.file); - return this.preload(this.fileObjectURL); - } - } - releasePreloadedFile() { - if (this.fileObjectURL) { - URL.revokeObjectURL(this.fileObjectURL); - this.fileObjectURL = null; - } - } - preload(url, callback) { - if (url && url !== this.getPreviewURL()) { - this.preloadingURL = url; - const operation = new ImagePreloadOperation(url); - return operation.then(_ref => { - let { - width, - height - } = _ref; - if (!this.getWidth() || !this.getHeight()) { - this.setAttributes({ - width, - height - }); - } - this.preloadingURL = null; - this.setPreviewURL(url); - return callback === null || callback === void 0 ? void 0 : callback(); - }).catch(() => { - this.preloadingURL = null; - return callback === null || callback === void 0 ? void 0 : callback(); - }); - } - } - } - _defineProperty(Attachment, "previewablePattern", /^image(\/(gif|png|webp|jpe?g)|$)/); - - class AttachmentPiece extends Piece { - static fromJSON(pieceJSON) { - return new this(Attachment.fromJSON(pieceJSON.attachment), pieceJSON.attributes); - } - constructor(attachment) { - super(...arguments); - this.attachment = attachment; - this.length = 1; - this.ensureAttachmentExclusivelyHasAttribute("href"); - if (!this.attachment.hasContent()) { - this.removeProhibitedAttributes(); - } - } - ensureAttachmentExclusivelyHasAttribute(attribute) { - if (this.hasAttribute(attribute)) { - if (!this.attachment.hasAttribute(attribute)) { - this.attachment.setAttributes(this.attributes.slice([attribute])); - } - this.attributes = this.attributes.remove(attribute); - } - } - removeProhibitedAttributes() { - const attributes = this.attributes.slice(AttachmentPiece.permittedAttributes); - if (!attributes.isEqualTo(this.attributes)) { - this.attributes = attributes; - } - } - getValue() { - return this.attachment; - } - isSerializable() { - return !this.attachment.isPending(); - } - getCaption() { - return this.attributes.get("caption") || ""; - } - isEqualTo(piece) { - var _piece$attachment; - return super.isEqualTo(piece) && this.attachment.id === (piece === null || piece === void 0 || (_piece$attachment = piece.attachment) === null || _piece$attachment === void 0 ? void 0 : _piece$attachment.id); - } - toString() { - return OBJECT_REPLACEMENT_CHARACTER; - } - toJSON() { - const json = super.toJSON(...arguments); - json.attachment = this.attachment; - return json; - } - getCacheKey() { - return [super.getCacheKey(...arguments), this.attachment.getCacheKey()].join("/"); - } - toConsole() { - return JSON.stringify(this.toString()); - } - } - _defineProperty(AttachmentPiece, "permittedAttributes", ["caption", "presentation"]); - Piece.registerType("attachment", AttachmentPiece); - - class StringPiece extends Piece { - static fromJSON(pieceJSON) { - return new this(pieceJSON.string, pieceJSON.attributes); - } - constructor(string) { - super(...arguments); - this.string = normalizeNewlines(string); - this.length = this.string.length; - } - getValue() { - return this.string; - } - toString() { - return this.string.toString(); - } - isBlockBreak() { - return this.toString() === "\n" && this.getAttribute("blockBreak") === true; - } - toJSON() { - const result = super.toJSON(...arguments); - result.string = this.string; - return result; - } - - // Splittable - - canBeConsolidatedWith(piece) { - return piece && this.hasSameConstructorAs(piece) && this.hasSameAttributesAsPiece(piece); - } - consolidateWith(piece) { - return new this.constructor(this.toString() + piece.toString(), this.attributes); - } - splitAtOffset(offset) { - let left, right; - if (offset === 0) { - left = null; - right = this; - } else if (offset === this.length) { - left = this; - right = null; - } else { - left = new this.constructor(this.string.slice(0, offset), this.attributes); - right = new this.constructor(this.string.slice(offset), this.attributes); - } - return [left, right]; - } - toConsole() { - let { - string - } = this; - if (string.length > 15) { - string = string.slice(0, 14) + "…"; - } - return JSON.stringify(string.toString()); - } - } - Piece.registerType("string", StringPiece); - - /* eslint-disable - prefer-const, - */ - class SplittableList extends TrixObject { - static box(objects) { - if (objects instanceof this) { - return objects; - } else { - return new this(objects); - } - } - constructor() { - let objects = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : []; - super(...arguments); - this.objects = objects.slice(0); - this.length = this.objects.length; - } - indexOf(object) { - return this.objects.indexOf(object); - } - splice() { - for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) { - args[_key] = arguments[_key]; - } - return new this.constructor(spliceArray(this.objects, ...args)); - } - eachObject(callback) { - return this.objects.map((object, index) => callback(object, index)); - } - insertObjectAtIndex(object, index) { - return this.splice(index, 0, object); - } - insertSplittableListAtIndex(splittableList, index) { - return this.splice(index, 0, ...splittableList.objects); - } - insertSplittableListAtPosition(splittableList, position) { - const [objects, index] = this.splitObjectAtPosition(position); - return new this.constructor(objects).insertSplittableListAtIndex(splittableList, index); - } - editObjectAtIndex(index, callback) { - return this.replaceObjectAtIndex(callback(this.objects[index]), index); - } - replaceObjectAtIndex(object, index) { - return this.splice(index, 1, object); - } - removeObjectAtIndex(index) { - return this.splice(index, 1); - } - getObjectAtIndex(index) { - return this.objects[index]; - } - getSplittableListInRange(range) { - const [objects, leftIndex, rightIndex] = this.splitObjectsAtRange(range); - return new this.constructor(objects.slice(leftIndex, rightIndex + 1)); - } - selectSplittableList(test) { - const objects = this.objects.filter(object => test(object)); - return new this.constructor(objects); - } - removeObjectsInRange(range) { - const [objects, leftIndex, rightIndex] = this.splitObjectsAtRange(range); - return new this.constructor(objects).splice(leftIndex, rightIndex - leftIndex + 1); - } - transformObjectsInRange(range, transform) { - const [objects, leftIndex, rightIndex] = this.splitObjectsAtRange(range); - const transformedObjects = objects.map((object, index) => leftIndex <= index && index <= rightIndex ? transform(object) : object); - return new this.constructor(transformedObjects); - } - splitObjectsAtRange(range) { - let rightOuterIndex; - let [objects, leftInnerIndex, offset] = this.splitObjectAtPosition(startOfRange(range)); - [objects, rightOuterIndex] = new this.constructor(objects).splitObjectAtPosition(endOfRange(range) + offset); - return [objects, leftInnerIndex, rightOuterIndex - 1]; - } - getObjectAtPosition(position) { - const { - index - } = this.findIndexAndOffsetAtPosition(position); - return this.objects[index]; - } - splitObjectAtPosition(position) { - let splitIndex, splitOffset; - const { - index, - offset - } = this.findIndexAndOffsetAtPosition(position); - const objects = this.objects.slice(0); - if (index != null) { - if (offset === 0) { - splitIndex = index; - splitOffset = 0; - } else { - const object = this.getObjectAtIndex(index); - const [leftObject, rightObject] = object.splitAtOffset(offset); - objects.splice(index, 1, leftObject, rightObject); - splitIndex = index + 1; - splitOffset = leftObject.getLength() - offset; - } - } else { - splitIndex = objects.length; - splitOffset = 0; - } - return [objects, splitIndex, splitOffset]; - } - consolidate() { - const objects = []; - let pendingObject = this.objects[0]; - this.objects.slice(1).forEach(object => { - var _pendingObject$canBeC, _pendingObject; - if ((_pendingObject$canBeC = (_pendingObject = pendingObject).canBeConsolidatedWith) !== null && _pendingObject$canBeC !== void 0 && _pendingObject$canBeC.call(_pendingObject, object)) { - pendingObject = pendingObject.consolidateWith(object); - } else { - objects.push(pendingObject); - pendingObject = object; - } - }); - if (pendingObject) { - objects.push(pendingObject); - } - return new this.constructor(objects); - } - consolidateFromIndexToIndex(startIndex, endIndex) { - const objects = this.objects.slice(0); - const objectsInRange = objects.slice(startIndex, endIndex + 1); - const consolidatedInRange = new this.constructor(objectsInRange).consolidate().toArray(); - return this.splice(startIndex, objectsInRange.length, ...consolidatedInRange); - } - findIndexAndOffsetAtPosition(position) { - let index; - let currentPosition = 0; - for (index = 0; index < this.objects.length; index++) { - const object = this.objects[index]; - const nextPosition = currentPosition + object.getLength(); - if (currentPosition <= position && position < nextPosition) { - return { - index, - offset: position - currentPosition - }; - } - currentPosition = nextPosition; - } - return { - index: null, - offset: null - }; - } - findPositionAtIndexAndOffset(index, offset) { - let position = 0; - for (let currentIndex = 0; currentIndex < this.objects.length; currentIndex++) { - const object = this.objects[currentIndex]; - if (currentIndex < index) { - position += object.getLength(); - } else if (currentIndex === index) { - position += offset; - break; - } - } - return position; - } - getEndPosition() { - if (this.endPosition == null) { - this.endPosition = 0; - this.objects.forEach(object => this.endPosition += object.getLength()); - } - return this.endPosition; - } - toString() { - return this.objects.join(""); - } - toArray() { - return this.objects.slice(0); - } - toJSON() { - return this.toArray(); - } - isEqualTo(splittableList) { - return super.isEqualTo(...arguments) || objectArraysAreEqual(this.objects, splittableList === null || splittableList === void 0 ? void 0 : splittableList.objects); - } - contentsForInspection() { - return { - objects: "[".concat(this.objects.map(object => object.inspect()).join(", "), "]") - }; - } - } - const objectArraysAreEqual = function (left) { - let right = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : []; - if (left.length !== right.length) { - return false; - } - let result = true; - for (let index = 0; index < left.length; index++) { - const object = left[index]; - if (result && !object.isEqualTo(right[index])) { - result = false; - } - } - return result; - }; - const startOfRange = range => range[0]; - const endOfRange = range => range[1]; - - class Text extends TrixObject { - static textForAttachmentWithAttributes(attachment, attributes) { - const piece = new AttachmentPiece(attachment, attributes); - return new this([piece]); - } - static textForStringWithAttributes(string, attributes) { - const piece = new StringPiece(string, attributes); - return new this([piece]); - } - static fromJSON(textJSON) { - const pieces = Array.from(textJSON).map(pieceJSON => Piece.fromJSON(pieceJSON)); - return new this(pieces); - } - constructor() { - let pieces = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : []; - super(...arguments); - const notEmpty = pieces.filter(piece => !piece.isEmpty()); - this.pieceList = new SplittableList(notEmpty); - } - copy() { - return this.copyWithPieceList(this.pieceList); - } - copyWithPieceList(pieceList) { - return new this.constructor(pieceList.consolidate().toArray()); - } - copyUsingObjectMap(objectMap) { - const pieces = this.getPieces().map(piece => objectMap.find(piece) || piece); - return new this.constructor(pieces); - } - appendText(text) { - return this.insertTextAtPosition(text, this.getLength()); - } - insertTextAtPosition(text, position) { - return this.copyWithPieceList(this.pieceList.insertSplittableListAtPosition(text.pieceList, position)); - } - removeTextAtRange(range) { - return this.copyWithPieceList(this.pieceList.removeObjectsInRange(range)); - } - replaceTextAtRange(text, range) { - return this.removeTextAtRange(range).insertTextAtPosition(text, range[0]); - } - moveTextFromRangeToPosition(range, position) { - if (range[0] <= position && position <= range[1]) return; - const text = this.getTextAtRange(range); - const length = text.getLength(); - if (range[0] < position) { - position -= length; - } - return this.removeTextAtRange(range).insertTextAtPosition(text, position); - } - addAttributeAtRange(attribute, value, range) { - const attributes = {}; - attributes[attribute] = value; - return this.addAttributesAtRange(attributes, range); - } - addAttributesAtRange(attributes, range) { - return this.copyWithPieceList(this.pieceList.transformObjectsInRange(range, piece => piece.copyWithAdditionalAttributes(attributes))); - } - removeAttributeAtRange(attribute, range) { - return this.copyWithPieceList(this.pieceList.transformObjectsInRange(range, piece => piece.copyWithoutAttribute(attribute))); - } - setAttributesAtRange(attributes, range) { - return this.copyWithPieceList(this.pieceList.transformObjectsInRange(range, piece => piece.copyWithAttributes(attributes))); - } - getAttributesAtPosition(position) { - var _this$pieceList$getOb; - return ((_this$pieceList$getOb = this.pieceList.getObjectAtPosition(position)) === null || _this$pieceList$getOb === void 0 ? void 0 : _this$pieceList$getOb.getAttributes()) || {}; - } - getCommonAttributes() { - const objects = Array.from(this.pieceList.toArray()).map(piece => piece.getAttributes()); - return Hash.fromCommonAttributesOfObjects(objects).toObject(); - } - getCommonAttributesAtRange(range) { - return this.getTextAtRange(range).getCommonAttributes() || {}; - } - getExpandedRangeForAttributeAtOffset(attributeName, offset) { - let right; - let left = right = offset; - const length = this.getLength(); - while (left > 0 && this.getCommonAttributesAtRange([left - 1, right])[attributeName]) { - left--; - } - while (right < length && this.getCommonAttributesAtRange([offset, right + 1])[attributeName]) { - right++; - } - return [left, right]; - } - getTextAtRange(range) { - return this.copyWithPieceList(this.pieceList.getSplittableListInRange(range)); - } - getStringAtRange(range) { - return this.pieceList.getSplittableListInRange(range).toString(); - } - getStringAtPosition(position) { - return this.getStringAtRange([position, position + 1]); - } - startsWithString(string) { - return this.getStringAtRange([0, string.length]) === string; - } - endsWithString(string) { - const length = this.getLength(); - return this.getStringAtRange([length - string.length, length]) === string; - } - getAttachmentPieces() { - return this.pieceList.toArray().filter(piece => !!piece.attachment); - } - getAttachments() { - return this.getAttachmentPieces().map(piece => piece.attachment); - } - getAttachmentAndPositionById(attachmentId) { - let position = 0; - for (const piece of this.pieceList.toArray()) { - var _piece$attachment; - if (((_piece$attachment = piece.attachment) === null || _piece$attachment === void 0 ? void 0 : _piece$attachment.id) === attachmentId) { - return { - attachment: piece.attachment, - position - }; - } - position += piece.length; - } - return { - attachment: null, - position: null - }; - } - getAttachmentById(attachmentId) { - const { - attachment - } = this.getAttachmentAndPositionById(attachmentId); - return attachment; - } - getRangeOfAttachment(attachment) { - const attachmentAndPosition = this.getAttachmentAndPositionById(attachment.id); - const position = attachmentAndPosition.position; - attachment = attachmentAndPosition.attachment; - if (attachment) { - return [position, position + 1]; - } - } - updateAttributesForAttachment(attributes, attachment) { - const range = this.getRangeOfAttachment(attachment); - if (range) { - return this.addAttributesAtRange(attributes, range); - } else { - return this; - } - } - getLength() { - return this.pieceList.getEndPosition(); - } - isEmpty() { - return this.getLength() === 0; - } - isEqualTo(text) { - var _text$pieceList; - return super.isEqualTo(text) || (text === null || text === void 0 || (_text$pieceList = text.pieceList) === null || _text$pieceList === void 0 ? void 0 : _text$pieceList.isEqualTo(this.pieceList)); - } - isBlockBreak() { - return this.getLength() === 1 && this.pieceList.getObjectAtIndex(0).isBlockBreak(); - } - eachPiece(callback) { - return this.pieceList.eachObject(callback); - } - getPieces() { - return this.pieceList.toArray(); - } - getPieceAtPosition(position) { - return this.pieceList.getObjectAtPosition(position); - } - contentsForInspection() { - return { - pieceList: this.pieceList.inspect() - }; - } - toSerializableText() { - const pieceList = this.pieceList.selectSplittableList(piece => piece.isSerializable()); - return this.copyWithPieceList(pieceList); - } - toString() { - return this.pieceList.toString(); - } - toJSON() { - return this.pieceList.toJSON(); - } - toConsole() { - return JSON.stringify(this.pieceList.toArray().map(piece => JSON.parse(piece.toConsole()))); - } - - // BIDI - - getDirection() { - return getDirection(this.toString()); - } - isRTL() { - return this.getDirection() === "rtl"; - } - } - - class Block extends TrixObject { - static fromJSON(blockJSON) { - const text = Text.fromJSON(blockJSON.text); - return new this(text, blockJSON.attributes, blockJSON.htmlAttributes); - } - constructor(text, attributes, htmlAttributes) { - super(...arguments); - this.text = applyBlockBreakToText(text || new Text()); - this.attributes = attributes || []; - this.htmlAttributes = htmlAttributes || {}; - } - isEmpty() { - return this.text.isBlockBreak(); - } - isEqualTo(block) { - if (super.isEqualTo(block)) return true; - return this.text.isEqualTo(block === null || block === void 0 ? void 0 : block.text) && arraysAreEqual(this.attributes, block === null || block === void 0 ? void 0 : block.attributes) && objectsAreEqual(this.htmlAttributes, block === null || block === void 0 ? void 0 : block.htmlAttributes); - } - copyWithText(text) { - return new Block(text, this.attributes, this.htmlAttributes); - } - copyWithoutText() { - return this.copyWithText(null); - } - copyWithAttributes(attributes) { - return new Block(this.text, attributes, this.htmlAttributes); - } - copyWithoutAttributes() { - return this.copyWithAttributes(null); - } - copyUsingObjectMap(objectMap) { - const mappedText = objectMap.find(this.text); - if (mappedText) { - return this.copyWithText(mappedText); - } else { - return this.copyWithText(this.text.copyUsingObjectMap(objectMap)); - } - } - addAttribute(attribute) { - const attributes = this.attributes.concat(expandAttribute(attribute)); - return this.copyWithAttributes(attributes); - } - addHTMLAttribute(attribute, value) { - const htmlAttributes = Object.assign({}, this.htmlAttributes, { - [attribute]: value - }); - return new Block(this.text, this.attributes, htmlAttributes); - } - removeAttribute(attribute) { - const { - listAttribute - } = getBlockConfig(attribute); - const attributes = removeLastValue(removeLastValue(this.attributes, attribute), listAttribute); - return this.copyWithAttributes(attributes); - } - removeLastAttribute() { - return this.removeAttribute(this.getLastAttribute()); - } - getLastAttribute() { - return getLastElement(this.attributes); - } - getAttributes() { - return this.attributes.slice(0); - } - getAttributeLevel() { - return this.attributes.length; - } - getAttributeAtLevel(level) { - return this.attributes[level - 1]; - } - hasAttribute(attributeName) { - return this.attributes.includes(attributeName); - } - hasAttributes() { - return this.getAttributeLevel() > 0; - } - getLastNestableAttribute() { - return getLastElement(this.getNestableAttributes()); - } - getNestableAttributes() { - return this.attributes.filter(attribute => getBlockConfig(attribute).nestable); - } - getNestingLevel() { - return this.getNestableAttributes().length; - } - decreaseNestingLevel() { - const attribute = this.getLastNestableAttribute(); - if (attribute) { - return this.removeAttribute(attribute); - } else { - return this; - } - } - increaseNestingLevel() { - const attribute = this.getLastNestableAttribute(); - if (attribute) { - const index = this.attributes.lastIndexOf(attribute); - const attributes = spliceArray(this.attributes, index + 1, 0, ...expandAttribute(attribute)); - return this.copyWithAttributes(attributes); - } else { - return this; - } - } - getListItemAttributes() { - return this.attributes.filter(attribute => getBlockConfig(attribute).listAttribute); - } - isListItem() { - var _getBlockConfig; - return (_getBlockConfig = getBlockConfig(this.getLastAttribute())) === null || _getBlockConfig === void 0 ? void 0 : _getBlockConfig.listAttribute; - } - isTerminalBlock() { - var _getBlockConfig2; - return (_getBlockConfig2 = getBlockConfig(this.getLastAttribute())) === null || _getBlockConfig2 === void 0 ? void 0 : _getBlockConfig2.terminal; - } - breaksOnReturn() { - var _getBlockConfig3; - return (_getBlockConfig3 = getBlockConfig(this.getLastAttribute())) === null || _getBlockConfig3 === void 0 ? void 0 : _getBlockConfig3.breakOnReturn; - } - findLineBreakInDirectionFromPosition(direction, position) { - const string = this.toString(); - let result; - switch (direction) { - case "forward": - result = string.indexOf("\n", position); - break; - case "backward": - result = string.slice(0, position).lastIndexOf("\n"); - } - if (result !== -1) { - return result; - } - } - contentsForInspection() { - return { - text: this.text.inspect(), - attributes: this.attributes - }; - } - toString() { - return this.text.toString(); - } - toJSON() { - return { - text: this.text, - attributes: this.attributes, - htmlAttributes: this.htmlAttributes - }; - } - - // BIDI - - getDirection() { - return this.text.getDirection(); - } - isRTL() { - return this.text.isRTL(); - } - - // Splittable - - getLength() { - return this.text.getLength(); - } - canBeConsolidatedWith(block) { - return !this.hasAttributes() && !block.hasAttributes() && this.getDirection() === block.getDirection(); - } - consolidateWith(block) { - const newlineText = Text.textForStringWithAttributes("\n"); - const text = this.getTextWithoutBlockBreak().appendText(newlineText); - return this.copyWithText(text.appendText(block.text)); - } - splitAtOffset(offset) { - let left, right; - if (offset === 0) { - left = null; - right = this; - } else if (offset === this.getLength()) { - left = this; - right = null; - } else { - left = this.copyWithText(this.text.getTextAtRange([0, offset])); - right = this.copyWithText(this.text.getTextAtRange([offset, this.getLength()])); - } - return [left, right]; - } - getBlockBreakPosition() { - return this.text.getLength() - 1; - } - getTextWithoutBlockBreak() { - if (textEndsInBlockBreak(this.text)) { - return this.text.getTextAtRange([0, this.getBlockBreakPosition()]); - } else { - return this.text.copy(); - } - } - - // Grouping - - canBeGrouped(depth) { - return this.attributes[depth]; - } - canBeGroupedWith(otherBlock, depth) { - const otherAttributes = otherBlock.getAttributes(); - const otherAttribute = otherAttributes[depth]; - const attribute = this.attributes[depth]; - return attribute === otherAttribute && !(getBlockConfig(attribute).group === false && !getListAttributeNames().includes(otherAttributes[depth + 1])) && (this.getDirection() === otherBlock.getDirection() || otherBlock.isEmpty()); - } - } - - // Block breaks - - const applyBlockBreakToText = function (text) { - text = unmarkExistingInnerBlockBreaksInText(text); - text = addBlockBreakToText(text); - return text; - }; - const unmarkExistingInnerBlockBreaksInText = function (text) { - let modified = false; - const pieces = text.getPieces(); - let innerPieces = pieces.slice(0, pieces.length - 1); - const lastPiece = pieces[pieces.length - 1]; - if (!lastPiece) return text; - innerPieces = innerPieces.map(piece => { - if (piece.isBlockBreak()) { - modified = true; - return unmarkBlockBreakPiece(piece); - } else { - return piece; - } - }); - if (modified) { - return new Text([...innerPieces, lastPiece]); - } else { - return text; - } - }; - const blockBreakText = Text.textForStringWithAttributes("\n", { - blockBreak: true - }); - const addBlockBreakToText = function (text) { - if (textEndsInBlockBreak(text)) { - return text; - } else { - return text.appendText(blockBreakText); - } - }; - const textEndsInBlockBreak = function (text) { - const length = text.getLength(); - if (length === 0) { - return false; - } - const endText = text.getTextAtRange([length - 1, length]); - return endText.isBlockBreak(); - }; - const unmarkBlockBreakPiece = piece => piece.copyWithoutAttribute("blockBreak"); - - // Attributes - - const expandAttribute = function (attribute) { - const { - listAttribute - } = getBlockConfig(attribute); - if (listAttribute) { - return [listAttribute, attribute]; - } else { - return [attribute]; - } - }; - - // Array helpers - - const getLastElement = array => array.slice(-1)[0]; - const removeLastValue = function (array, value) { - const index = array.lastIndexOf(value); - if (index === -1) { - return array; - } else { - return spliceArray(array, index, 1); - } - }; - - class Document extends TrixObject { - static fromJSON(documentJSON) { - const blocks = Array.from(documentJSON).map(blockJSON => Block.fromJSON(blockJSON)); - return new this(blocks); - } - static fromString(string, textAttributes) { - const text = Text.textForStringWithAttributes(string, textAttributes); - return new this([new Block(text)]); - } - constructor() { - let blocks = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : []; - super(...arguments); - if (blocks.length === 0) { - blocks = [new Block()]; - } - this.blockList = SplittableList.box(blocks); - } - isEmpty() { - const block = this.getBlockAtIndex(0); - return this.blockList.length === 1 && block.isEmpty() && !block.hasAttributes(); - } - copy() { - let options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; - const blocks = options.consolidateBlocks ? this.blockList.consolidate().toArray() : this.blockList.toArray(); - return new this.constructor(blocks); - } - copyUsingObjectsFromDocument(sourceDocument) { - const objectMap = new ObjectMap(sourceDocument.getObjects()); - return this.copyUsingObjectMap(objectMap); - } - copyUsingObjectMap(objectMap) { - const blocks = this.getBlocks().map(block => { - const mappedBlock = objectMap.find(block); - return mappedBlock || block.copyUsingObjectMap(objectMap); - }); - return new this.constructor(blocks); - } - copyWithBaseBlockAttributes() { - let blockAttributes = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : []; - const blocks = this.getBlocks().map(block => { - const attributes = blockAttributes.concat(block.getAttributes()); - return block.copyWithAttributes(attributes); - }); - return new this.constructor(blocks); - } - replaceBlock(oldBlock, newBlock) { - const index = this.blockList.indexOf(oldBlock); - if (index === -1) { - return this; - } - return new this.constructor(this.blockList.replaceObjectAtIndex(newBlock, index)); - } - insertDocumentAtRange(document, range) { - const { - blockList - } = document; - range = normalizeRange(range); - let [position] = range; - const { - index, - offset - } = this.locationFromPosition(position); - let result = this; - const block = this.getBlockAtPosition(position); - if (rangeIsCollapsed(range) && block.isEmpty() && !block.hasAttributes()) { - result = new this.constructor(result.blockList.removeObjectAtIndex(index)); - } else if (block.getBlockBreakPosition() === offset) { - position++; - } - result = result.removeTextAtRange(range); - return new this.constructor(result.blockList.insertSplittableListAtPosition(blockList, position)); - } - mergeDocumentAtRange(document, range) { - let formattedDocument, result; - range = normalizeRange(range); - const [startPosition] = range; - const startLocation = this.locationFromPosition(startPosition); - const blockAttributes = this.getBlockAtIndex(startLocation.index).getAttributes(); - const baseBlockAttributes = document.getBaseBlockAttributes(); - const trailingBlockAttributes = blockAttributes.slice(-baseBlockAttributes.length); - if (arraysAreEqual(baseBlockAttributes, trailingBlockAttributes)) { - const leadingBlockAttributes = blockAttributes.slice(0, -baseBlockAttributes.length); - formattedDocument = document.copyWithBaseBlockAttributes(leadingBlockAttributes); - } else { - formattedDocument = document.copy({ - consolidateBlocks: true - }).copyWithBaseBlockAttributes(blockAttributes); - } - const blockCount = formattedDocument.getBlockCount(); - const firstBlock = formattedDocument.getBlockAtIndex(0); - if (arraysAreEqual(blockAttributes, firstBlock.getAttributes())) { - const firstText = firstBlock.getTextWithoutBlockBreak(); - result = this.insertTextAtRange(firstText, range); - if (blockCount > 1) { - formattedDocument = new this.constructor(formattedDocument.getBlocks().slice(1)); - const position = startPosition + firstText.getLength(); - result = result.insertDocumentAtRange(formattedDocument, position); - } - } else { - result = this.insertDocumentAtRange(formattedDocument, range); - } - return result; - } - insertTextAtRange(text, range) { - range = normalizeRange(range); - const [startPosition] = range; - const { - index, - offset - } = this.locationFromPosition(startPosition); - const document = this.removeTextAtRange(range); - return new this.constructor(document.blockList.editObjectAtIndex(index, block => block.copyWithText(block.text.insertTextAtPosition(text, offset)))); - } - removeTextAtRange(range) { - let blocks; - range = normalizeRange(range); - const [leftPosition, rightPosition] = range; - if (rangeIsCollapsed(range)) { - return this; - } - const [leftLocation, rightLocation] = Array.from(this.locationRangeFromRange(range)); - const leftIndex = leftLocation.index; - const leftOffset = leftLocation.offset; - const leftBlock = this.getBlockAtIndex(leftIndex); - const rightIndex = rightLocation.index; - const rightOffset = rightLocation.offset; - const rightBlock = this.getBlockAtIndex(rightIndex); - const removeRightNewline = rightPosition - leftPosition === 1 && leftBlock.getBlockBreakPosition() === leftOffset && rightBlock.getBlockBreakPosition() !== rightOffset && rightBlock.text.getStringAtPosition(rightOffset) === "\n"; - if (removeRightNewline) { - blocks = this.blockList.editObjectAtIndex(rightIndex, block => block.copyWithText(block.text.removeTextAtRange([rightOffset, rightOffset + 1]))); - } else { - let block; - const leftText = leftBlock.text.getTextAtRange([0, leftOffset]); - const rightText = rightBlock.text.getTextAtRange([rightOffset, rightBlock.getLength()]); - const text = leftText.appendText(rightText); - const removingLeftBlock = leftIndex !== rightIndex && leftOffset === 0; - const useRightBlock = removingLeftBlock && leftBlock.getAttributeLevel() >= rightBlock.getAttributeLevel(); - if (useRightBlock) { - block = rightBlock.copyWithText(text); - } else { - block = leftBlock.copyWithText(text); - } - const affectedBlockCount = rightIndex + 1 - leftIndex; - blocks = this.blockList.splice(leftIndex, affectedBlockCount, block); - } - return new this.constructor(blocks); - } - moveTextFromRangeToPosition(range, position) { - let text; - range = normalizeRange(range); - const [startPosition, endPosition] = range; - if (startPosition <= position && position <= endPosition) { - return this; - } - let document = this.getDocumentAtRange(range); - let result = this.removeTextAtRange(range); - const movingRightward = startPosition < position; - if (movingRightward) { - position -= document.getLength(); - } - const [firstBlock, ...blocks] = document.getBlocks(); - if (blocks.length === 0) { - text = firstBlock.getTextWithoutBlockBreak(); - if (movingRightward) { - position += 1; - } - } else { - text = firstBlock.text; - } - result = result.insertTextAtRange(text, position); - if (blocks.length === 0) { - return result; - } - document = new this.constructor(blocks); - position += text.getLength(); - return result.insertDocumentAtRange(document, position); - } - addAttributeAtRange(attribute, value, range) { - let { - blockList - } = this; - this.eachBlockAtRange(range, (block, textRange, index) => blockList = blockList.editObjectAtIndex(index, function () { - if (getBlockConfig(attribute)) { - return block.addAttribute(attribute, value); - } else { - if (textRange[0] === textRange[1]) { - return block; - } else { - return block.copyWithText(block.text.addAttributeAtRange(attribute, value, textRange)); - } - } - })); - return new this.constructor(blockList); - } - addAttribute(attribute, value) { - let { - blockList - } = this; - this.eachBlock((block, index) => blockList = blockList.editObjectAtIndex(index, () => block.addAttribute(attribute, value))); - return new this.constructor(blockList); - } - removeAttributeAtRange(attribute, range) { - let { - blockList - } = this; - this.eachBlockAtRange(range, function (block, textRange, index) { - if (getBlockConfig(attribute)) { - blockList = blockList.editObjectAtIndex(index, () => block.removeAttribute(attribute)); - } else if (textRange[0] !== textRange[1]) { - blockList = blockList.editObjectAtIndex(index, () => block.copyWithText(block.text.removeAttributeAtRange(attribute, textRange))); - } - }); - return new this.constructor(blockList); - } - updateAttributesForAttachment(attributes, attachment) { - const range = this.getRangeOfAttachment(attachment); - const [startPosition] = Array.from(range); - const { - index - } = this.locationFromPosition(startPosition); - const text = this.getTextAtIndex(index); - return new this.constructor(this.blockList.editObjectAtIndex(index, block => block.copyWithText(text.updateAttributesForAttachment(attributes, attachment)))); - } - removeAttributeForAttachment(attribute, attachment) { - const range = this.getRangeOfAttachment(attachment); - return this.removeAttributeAtRange(attribute, range); - } - setHTMLAttributeAtPosition(position, name, value) { - const block = this.getBlockAtPosition(position); - const updatedBlock = block.addHTMLAttribute(name, value); - return this.replaceBlock(block, updatedBlock); - } - insertBlockBreakAtRange(range) { - let blocks; - range = normalizeRange(range); - const [startPosition] = range; - const { - offset - } = this.locationFromPosition(startPosition); - const document = this.removeTextAtRange(range); - if (offset === 0) { - blocks = [new Block()]; - } - return new this.constructor(document.blockList.insertSplittableListAtPosition(new SplittableList(blocks), startPosition)); - } - applyBlockAttributeAtRange(attributeName, value, range) { - const expanded = this.expandRangeToLineBreaksAndSplitBlocks(range); - let document = expanded.document; - range = expanded.range; - const blockConfig = getBlockConfig(attributeName); - if (blockConfig.listAttribute) { - document = document.removeLastListAttributeAtRange(range, { - exceptAttributeName: attributeName - }); - const converted = document.convertLineBreaksToBlockBreaksInRange(range); - document = converted.document; - range = converted.range; - } else if (blockConfig.exclusive) { - document = document.removeBlockAttributesAtRange(range); - } else if (blockConfig.terminal) { - document = document.removeLastTerminalAttributeAtRange(range); - } else { - document = document.consolidateBlocksAtRange(range); - } - return document.addAttributeAtRange(attributeName, value, range); - } - removeLastListAttributeAtRange(range) { - let options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; - let { - blockList - } = this; - this.eachBlockAtRange(range, function (block, textRange, index) { - const lastAttributeName = block.getLastAttribute(); - if (!lastAttributeName) { - return; - } - if (!getBlockConfig(lastAttributeName).listAttribute) { - return; - } - if (lastAttributeName === options.exceptAttributeName) { - return; - } - blockList = blockList.editObjectAtIndex(index, () => block.removeAttribute(lastAttributeName)); - }); - return new this.constructor(blockList); - } - removeLastTerminalAttributeAtRange(range) { - let { - blockList - } = this; - this.eachBlockAtRange(range, function (block, textRange, index) { - const lastAttributeName = block.getLastAttribute(); - if (!lastAttributeName) { - return; - } - if (!getBlockConfig(lastAttributeName).terminal) { - return; - } - blockList = blockList.editObjectAtIndex(index, () => block.removeAttribute(lastAttributeName)); - }); - return new this.constructor(blockList); - } - removeBlockAttributesAtRange(range) { - let { - blockList - } = this; - this.eachBlockAtRange(range, function (block, textRange, index) { - if (block.hasAttributes()) { - blockList = blockList.editObjectAtIndex(index, () => block.copyWithoutAttributes()); - } - }); - return new this.constructor(blockList); - } - expandRangeToLineBreaksAndSplitBlocks(range) { - let position; - range = normalizeRange(range); - let [startPosition, endPosition] = range; - const startLocation = this.locationFromPosition(startPosition); - const endLocation = this.locationFromPosition(endPosition); - let document = this; - const startBlock = document.getBlockAtIndex(startLocation.index); - startLocation.offset = startBlock.findLineBreakInDirectionFromPosition("backward", startLocation.offset); - if (startLocation.offset != null) { - position = document.positionFromLocation(startLocation); - document = document.insertBlockBreakAtRange([position, position + 1]); - endLocation.index += 1; - endLocation.offset -= document.getBlockAtIndex(startLocation.index).getLength(); - startLocation.index += 1; - } - startLocation.offset = 0; - if (endLocation.offset === 0 && endLocation.index > startLocation.index) { - endLocation.index -= 1; - endLocation.offset = document.getBlockAtIndex(endLocation.index).getBlockBreakPosition(); - } else { - const endBlock = document.getBlockAtIndex(endLocation.index); - if (endBlock.text.getStringAtRange([endLocation.offset - 1, endLocation.offset]) === "\n") { - endLocation.offset -= 1; - } else { - endLocation.offset = endBlock.findLineBreakInDirectionFromPosition("forward", endLocation.offset); - } - if (endLocation.offset !== endBlock.getBlockBreakPosition()) { - position = document.positionFromLocation(endLocation); - document = document.insertBlockBreakAtRange([position, position + 1]); - } - } - startPosition = document.positionFromLocation(startLocation); - endPosition = document.positionFromLocation(endLocation); - range = normalizeRange([startPosition, endPosition]); - return { - document, - range - }; - } - convertLineBreaksToBlockBreaksInRange(range) { - range = normalizeRange(range); - let [position] = range; - const string = this.getStringAtRange(range).slice(0, -1); - let document = this; - string.replace(/.*?\n/g, function (match) { - position += match.length; - document = document.insertBlockBreakAtRange([position - 1, position]); - }); - return { - document, - range - }; - } - consolidateBlocksAtRange(range) { - range = normalizeRange(range); - const [startPosition, endPosition] = range; - const startIndex = this.locationFromPosition(startPosition).index; - const endIndex = this.locationFromPosition(endPosition).index; - return new this.constructor(this.blockList.consolidateFromIndexToIndex(startIndex, endIndex)); - } - getDocumentAtRange(range) { - range = normalizeRange(range); - const blocks = this.blockList.getSplittableListInRange(range).toArray(); - return new this.constructor(blocks); - } - getStringAtRange(range) { - let endIndex; - const array = range = normalizeRange(range), - endPosition = array[array.length - 1]; - if (endPosition !== this.getLength()) { - endIndex = -1; - } - return this.getDocumentAtRange(range).toString().slice(0, endIndex); - } - getBlockAtIndex(index) { - return this.blockList.getObjectAtIndex(index); - } - getBlockAtPosition(position) { - const { - index - } = this.locationFromPosition(position); - return this.getBlockAtIndex(index); - } - getTextAtIndex(index) { - var _this$getBlockAtIndex; - return (_this$getBlockAtIndex = this.getBlockAtIndex(index)) === null || _this$getBlockAtIndex === void 0 ? void 0 : _this$getBlockAtIndex.text; - } - getTextAtPosition(position) { - const { - index - } = this.locationFromPosition(position); - return this.getTextAtIndex(index); - } - getPieceAtPosition(position) { - const { - index, - offset - } = this.locationFromPosition(position); - return this.getTextAtIndex(index).getPieceAtPosition(offset); - } - getCharacterAtPosition(position) { - const { - index, - offset - } = this.locationFromPosition(position); - return this.getTextAtIndex(index).getStringAtRange([offset, offset + 1]); - } - getLength() { - return this.blockList.getEndPosition(); - } - getBlocks() { - return this.blockList.toArray(); - } - getBlockCount() { - return this.blockList.length; - } - getEditCount() { - return this.editCount; - } - eachBlock(callback) { - return this.blockList.eachObject(callback); - } - eachBlockAtRange(range, callback) { - let block, textRange; - range = normalizeRange(range); - const [startPosition, endPosition] = range; - const startLocation = this.locationFromPosition(startPosition); - const endLocation = this.locationFromPosition(endPosition); - if (startLocation.index === endLocation.index) { - block = this.getBlockAtIndex(startLocation.index); - textRange = [startLocation.offset, endLocation.offset]; - return callback(block, textRange, startLocation.index); - } else { - for (let index = startLocation.index; index <= endLocation.index; index++) { - block = this.getBlockAtIndex(index); - if (block) { - switch (index) { - case startLocation.index: - textRange = [startLocation.offset, block.text.getLength()]; - break; - case endLocation.index: - textRange = [0, endLocation.offset]; - break; - default: - textRange = [0, block.text.getLength()]; - } - callback(block, textRange, index); - } - } - } - } - getCommonAttributesAtRange(range) { - range = normalizeRange(range); - const [startPosition] = range; - if (rangeIsCollapsed(range)) { - return this.getCommonAttributesAtPosition(startPosition); - } else { - const textAttributes = []; - const blockAttributes = []; - this.eachBlockAtRange(range, function (block, textRange) { - if (textRange[0] !== textRange[1]) { - textAttributes.push(block.text.getCommonAttributesAtRange(textRange)); - return blockAttributes.push(attributesForBlock(block)); - } - }); - return Hash.fromCommonAttributesOfObjects(textAttributes).merge(Hash.fromCommonAttributesOfObjects(blockAttributes)).toObject(); - } - } - getCommonAttributesAtPosition(position) { - let key, value; - const { - index, - offset - } = this.locationFromPosition(position); - const block = this.getBlockAtIndex(index); - if (!block) { - return {}; - } - const commonAttributes = attributesForBlock(block); - const attributes = block.text.getAttributesAtPosition(offset); - const attributesLeft = block.text.getAttributesAtPosition(offset - 1); - const inheritableAttributes = Object.keys(text_attributes).filter(key => { - return text_attributes[key].inheritable; - }); - for (key in attributesLeft) { - value = attributesLeft[key]; - if (value === attributes[key] || inheritableAttributes.includes(key)) { - commonAttributes[key] = value; - } - } - return commonAttributes; - } - getRangeOfCommonAttributeAtPosition(attributeName, position) { - const { - index, - offset - } = this.locationFromPosition(position); - const text = this.getTextAtIndex(index); - const [startOffset, endOffset] = Array.from(text.getExpandedRangeForAttributeAtOffset(attributeName, offset)); - const start = this.positionFromLocation({ - index, - offset: startOffset - }); - const end = this.positionFromLocation({ - index, - offset: endOffset - }); - return normalizeRange([start, end]); - } - getBaseBlockAttributes() { - let baseBlockAttributes = this.getBlockAtIndex(0).getAttributes(); - for (let blockIndex = 1; blockIndex < this.getBlockCount(); blockIndex++) { - const blockAttributes = this.getBlockAtIndex(blockIndex).getAttributes(); - const lastAttributeIndex = Math.min(baseBlockAttributes.length, blockAttributes.length); - baseBlockAttributes = (() => { - const result = []; - for (let index = 0; index < lastAttributeIndex; index++) { - if (blockAttributes[index] !== baseBlockAttributes[index]) { - break; - } - result.push(blockAttributes[index]); - } - return result; - })(); - } - return baseBlockAttributes; - } - getAttachmentById(attachmentId) { - for (const attachment of this.getAttachments()) { - if (attachment.id === attachmentId) { - return attachment; - } - } - } - getAttachmentPieces() { - let attachmentPieces = []; - this.blockList.eachObject(_ref => { - let { - text - } = _ref; - return attachmentPieces = attachmentPieces.concat(text.getAttachmentPieces()); - }); - return attachmentPieces; - } - getAttachments() { - return this.getAttachmentPieces().map(piece => piece.attachment); - } - getRangeOfAttachment(attachment) { - let position = 0; - const iterable = this.blockList.toArray(); - for (let index = 0; index < iterable.length; index++) { - const { - text - } = iterable[index]; - const textRange = text.getRangeOfAttachment(attachment); - if (textRange) { - return normalizeRange([position + textRange[0], position + textRange[1]]); - } - position += text.getLength(); - } - } - getLocationRangeOfAttachment(attachment) { - const range = this.getRangeOfAttachment(attachment); - return this.locationRangeFromRange(range); - } - getAttachmentPieceForAttachment(attachment) { - for (const piece of this.getAttachmentPieces()) { - if (piece.attachment === attachment) { - return piece; - } - } - } - findRangesForBlockAttribute(attributeName) { - let position = 0; - const ranges = []; - this.getBlocks().forEach(block => { - const length = block.getLength(); - if (block.hasAttribute(attributeName)) { - ranges.push([position, position + length]); - } - position += length; - }); - return ranges; - } - findRangesForTextAttribute(attributeName) { - let { - withValue - } = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; - let position = 0; - let range = []; - const ranges = []; - const match = function (piece) { - if (withValue) { - return piece.getAttribute(attributeName) === withValue; - } else { - return piece.hasAttribute(attributeName); - } - }; - this.getPieces().forEach(piece => { - const length = piece.getLength(); - if (match(piece)) { - if (range[1] === position) { - range[1] = position + length; - } else { - ranges.push(range = [position, position + length]); - } - } - position += length; - }); - return ranges; - } - locationFromPosition(position) { - const location = this.blockList.findIndexAndOffsetAtPosition(Math.max(0, position)); - if (location.index != null) { - return location; - } else { - const blocks = this.getBlocks(); - return { - index: blocks.length - 1, - offset: blocks[blocks.length - 1].getLength() - }; - } - } - positionFromLocation(location) { - return this.blockList.findPositionAtIndexAndOffset(location.index, location.offset); - } - locationRangeFromPosition(position) { - return normalizeRange(this.locationFromPosition(position)); - } - locationRangeFromRange(range) { - range = normalizeRange(range); - if (!range) return; - const [startPosition, endPosition] = Array.from(range); - const startLocation = this.locationFromPosition(startPosition); - const endLocation = this.locationFromPosition(endPosition); - return normalizeRange([startLocation, endLocation]); - } - rangeFromLocationRange(locationRange) { - let rightPosition; - locationRange = normalizeRange(locationRange); - const leftPosition = this.positionFromLocation(locationRange[0]); - if (!rangeIsCollapsed(locationRange)) { - rightPosition = this.positionFromLocation(locationRange[1]); - } - return normalizeRange([leftPosition, rightPosition]); - } - isEqualTo(document) { - return this.blockList.isEqualTo(document === null || document === void 0 ? void 0 : document.blockList); - } - getTexts() { - return this.getBlocks().map(block => block.text); - } - getPieces() { - const pieces = []; - Array.from(this.getTexts()).forEach(text => { - pieces.push(...Array.from(text.getPieces() || [])); - }); - return pieces; - } - getObjects() { - return this.getBlocks().concat(this.getTexts()).concat(this.getPieces()); - } - toSerializableDocument() { - const blocks = []; - this.blockList.eachObject(block => blocks.push(block.copyWithText(block.text.toSerializableText()))); - return new this.constructor(blocks); - } - toString() { - return this.blockList.toString(); - } - toJSON() { - return this.blockList.toJSON(); - } - toConsole() { - return JSON.stringify(this.blockList.toArray().map(block => JSON.parse(block.text.toConsole()))); - } - } - const attributesForBlock = function (block) { - const attributes = {}; - const attributeName = block.getLastAttribute(); - if (attributeName) { - attributes[attributeName] = true; - } - return attributes; - }; - - /* eslint-disable - no-case-declarations, - no-irregular-whitespace, - */ - const pieceForString = function (string) { - let attributes = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; - const type = "string"; - string = normalizeSpaces(string); - return { - string, - attributes, - type - }; - }; - const pieceForAttachment = function (attachment) { - let attributes = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; - const type = "attachment"; - return { - attachment, - attributes, - type - }; - }; - const blockForAttributes = function () { - let attributes = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; - let htmlAttributes = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; - const text = []; - return { - text, - attributes, - htmlAttributes - }; - }; - const parseTrixDataAttribute = (element, name) => { - try { - return JSON.parse(element.getAttribute("data-trix-".concat(name))); - } catch (error) { - return {}; - } - }; - const getImageDimensions = element => { - const width = element.getAttribute("width"); - const height = element.getAttribute("height"); - const dimensions = {}; - if (width) { - dimensions.width = parseInt(width, 10); - } - if (height) { - dimensions.height = parseInt(height, 10); - } - return dimensions; - }; - class HTMLParser extends BasicObject { - static parse(html, options) { - const parser = new this(html, options); - parser.parse(); - return parser; - } - constructor(html) { - let { - referenceElement, - purifyOptions - } = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; - super(...arguments); - this.html = html; - this.referenceElement = referenceElement; - this.purifyOptions = purifyOptions; - this.blocks = []; - this.blockElements = []; - this.processedElements = []; - } - getDocument() { - return Document.fromJSON(this.blocks); - } - - // HTML parsing - - parse() { - try { - this.createHiddenContainer(); - HTMLSanitizer.setHTML(this.containerElement, this.html, { - purifyOptions: this.purifyOptions - }); - const walker = walkTree(this.containerElement, { - usingFilter: nodeFilter - }); - while (walker.nextNode()) { - this.processNode(walker.currentNode); - } - return this.translateBlockElementMarginsToNewlines(); - } finally { - this.removeHiddenContainer(); - } - } - createHiddenContainer() { - if (this.referenceElement) { - this.containerElement = this.referenceElement.cloneNode(false); - this.containerElement.removeAttribute("id"); - this.containerElement.setAttribute("data-trix-internal", ""); - this.containerElement.style.display = "none"; - return this.referenceElement.parentNode.insertBefore(this.containerElement, this.referenceElement.nextSibling); - } else { - this.containerElement = makeElement({ - tagName: "div", - style: { - display: "none" - } - }); - return document.body.appendChild(this.containerElement); - } - } - removeHiddenContainer() { - return removeNode(this.containerElement); - } - processNode(node) { - switch (node.nodeType) { - case Node.TEXT_NODE: - if (!this.isInsignificantTextNode(node)) { - this.appendBlockForTextNode(node); - return this.processTextNode(node); - } - break; - case Node.ELEMENT_NODE: - this.appendBlockForElement(node); - return this.processElement(node); - } - } - appendBlockForTextNode(node) { - const element = node.parentNode; - if (element === this.currentBlockElement && this.isBlockElement(node.previousSibling)) { - return this.appendStringWithAttributes("\n"); - } else if (element === this.containerElement || this.isBlockElement(element)) { - var _this$currentBlock; - const attributes = this.getBlockAttributes(element); - const htmlAttributes = this.getBlockHTMLAttributes(element); - if (!arraysAreEqual(attributes, (_this$currentBlock = this.currentBlock) === null || _this$currentBlock === void 0 ? void 0 : _this$currentBlock.attributes)) { - this.currentBlock = this.appendBlockForAttributesWithElement(attributes, element, htmlAttributes); - this.currentBlockElement = element; - } - } - } - appendBlockForElement(element) { - const elementIsBlockElement = this.isBlockElement(element); - const currentBlockContainsElement = elementContainsNode(this.currentBlockElement, element); - if (elementIsBlockElement && !this.isBlockElement(element.firstChild)) { - if (!this.isInsignificantTextNode(element.firstChild) || !this.isBlockElement(element.firstElementChild)) { - const attributes = this.getBlockAttributes(element); - const htmlAttributes = this.getBlockHTMLAttributes(element); - if (element.firstChild) { - if (!(currentBlockContainsElement && arraysAreEqual(attributes, this.currentBlock.attributes))) { - this.currentBlock = this.appendBlockForAttributesWithElement(attributes, element, htmlAttributes); - this.currentBlockElement = element; - } else { - return this.appendStringWithAttributes("\n"); - } - } - } - } else if (this.currentBlockElement && !currentBlockContainsElement && !elementIsBlockElement) { - const parentBlockElement = this.findParentBlockElement(element); - if (parentBlockElement) { - return this.appendBlockForElement(parentBlockElement); - } else { - this.currentBlock = this.appendEmptyBlock(); - this.currentBlockElement = null; - } - } - } - findParentBlockElement(element) { - let { - parentElement - } = element; - while (parentElement && parentElement !== this.containerElement) { - if (this.isBlockElement(parentElement) && this.blockElements.includes(parentElement)) { - return parentElement; - } else { - parentElement = parentElement.parentElement; - } - } - return null; - } - processTextNode(node) { - let string = node.data; - if (!elementCanDisplayPreformattedText(node.parentNode)) { - var _node$previousSibling; - string = squishBreakableWhitespace(string); - if (stringEndsWithWhitespace((_node$previousSibling = node.previousSibling) === null || _node$previousSibling === void 0 ? void 0 : _node$previousSibling.textContent)) { - string = leftTrimBreakableWhitespace(string); - } - } - return this.appendStringWithAttributes(string, this.getTextAttributes(node.parentNode)); - } - processElement(element) { - let attributes; - if (nodeIsAttachmentElement(element)) { - attributes = parseTrixDataAttribute(element, "attachment"); - if (Object.keys(attributes).length) { - const textAttributes = this.getTextAttributes(element); - this.appendAttachmentWithAttributes(attributes, textAttributes); - // We have everything we need so avoid processing inner nodes - element.innerHTML = ""; - } - return this.processedElements.push(element); - } else { - switch (tagName(element)) { - case "br": - if (!this.isExtraBR(element) && !this.isBlockElement(element.nextSibling)) { - this.appendStringWithAttributes("\n", this.getTextAttributes(element)); - } - return this.processedElements.push(element); - case "img": - attributes = { - url: element.getAttribute("src"), - contentType: "image" - }; - const object = getImageDimensions(element); - for (const key in object) { - const value = object[key]; - attributes[key] = value; - } - this.appendAttachmentWithAttributes(attributes, this.getTextAttributes(element)); - return this.processedElements.push(element); - case "tr": - if (this.needsTableSeparator(element)) { - return this.appendStringWithAttributes(parser.tableRowSeparator); - } - break; - case "td": - if (this.needsTableSeparator(element)) { - return this.appendStringWithAttributes(parser.tableCellSeparator); - } - break; - } - } - } - - // Document construction - - appendBlockForAttributesWithElement(attributes, element) { - let htmlAttributes = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {}; - this.blockElements.push(element); - const block = blockForAttributes(attributes, htmlAttributes); - this.blocks.push(block); - return block; - } - appendEmptyBlock() { - return this.appendBlockForAttributesWithElement([], null); - } - appendStringWithAttributes(string, attributes) { - return this.appendPiece(pieceForString(string, attributes)); - } - appendAttachmentWithAttributes(attachment, attributes) { - return this.appendPiece(pieceForAttachment(attachment, attributes)); - } - appendPiece(piece) { - if (this.blocks.length === 0) { - this.appendEmptyBlock(); - } - return this.blocks[this.blocks.length - 1].text.push(piece); - } - appendStringToTextAtIndex(string, index) { - const { - text - } = this.blocks[index]; - const piece = text[text.length - 1]; - if ((piece === null || piece === void 0 ? void 0 : piece.type) === "string") { - piece.string += string; - } else { - return text.push(pieceForString(string)); - } - } - prependStringToTextAtIndex(string, index) { - const { - text - } = this.blocks[index]; - const piece = text[0]; - if ((piece === null || piece === void 0 ? void 0 : piece.type) === "string") { - piece.string = string + piece.string; - } else { - return text.unshift(pieceForString(string)); - } - } - - // Attribute parsing - - getTextAttributes(element) { - let value; - const attributes = {}; - for (const attribute in text_attributes) { - const configAttr = text_attributes[attribute]; - if (configAttr.tagName && findClosestElementFromNode(element, { - matchingSelector: configAttr.tagName, - untilNode: this.containerElement - })) { - attributes[attribute] = true; - } else if (configAttr.parser) { - value = configAttr.parser(element); - if (value) { - let attributeInheritedFromBlock = false; - for (const blockElement of this.findBlockElementAncestors(element)) { - if (configAttr.parser(blockElement) === value) { - attributeInheritedFromBlock = true; - break; - } - } - if (!attributeInheritedFromBlock) { - attributes[attribute] = value; - } - } - } else if (configAttr.styleProperty) { - value = element.style[configAttr.styleProperty]; - if (value) { - attributes[attribute] = value; - } - } - } - if (nodeIsAttachmentElement(element)) { - const object = parseTrixDataAttribute(element, "attributes"); - for (const key in object) { - value = object[key]; - attributes[key] = value; - } - } - return attributes; - } - getBlockAttributes(element) { - const attributes$1 = []; - while (element && element !== this.containerElement) { - for (const attribute in attributes) { - const attrConfig = attributes[attribute]; - if (attrConfig.parse !== false) { - if (tagName(element) === attrConfig.tagName) { - var _attrConfig$test; - if ((_attrConfig$test = attrConfig.test) !== null && _attrConfig$test !== void 0 && _attrConfig$test.call(attrConfig, element) || !attrConfig.test) { - attributes$1.push(attribute); - if (attrConfig.listAttribute) { - attributes$1.push(attrConfig.listAttribute); - } - } - } - } - } - element = element.parentNode; - } - return attributes$1.reverse(); - } - getBlockHTMLAttributes(element) { - const attributes$1 = {}; - const blockConfig = Object.values(attributes).find(settings => settings.tagName === tagName(element)); - const allowedAttributes = (blockConfig === null || blockConfig === void 0 ? void 0 : blockConfig.htmlAttributes) || []; - allowedAttributes.forEach(attribute => { - if (element.hasAttribute(attribute)) { - attributes$1[attribute] = element.getAttribute(attribute); - } - }); - return attributes$1; - } - findBlockElementAncestors(element) { - const ancestors = []; - while (element && element !== this.containerElement) { - const tag = tagName(element); - if (getBlockTagNames().includes(tag)) { - ancestors.push(element); - } - element = element.parentNode; - } - return ancestors; - } - - // Element inspection - - isBlockElement(element) { - if ((element === null || element === void 0 ? void 0 : element.nodeType) !== Node.ELEMENT_NODE) return; - if (nodeIsAttachmentElement(element)) return; - if (findClosestElementFromNode(element, { - matchingSelector: "td", - untilNode: this.containerElement - })) return; - return getBlockTagNames().includes(tagName(element)) || window.getComputedStyle(element).display === "block"; - } - isInsignificantTextNode(node) { - if ((node === null || node === void 0 ? void 0 : node.nodeType) !== Node.TEXT_NODE) return; - if (!stringIsAllBreakableWhitespace(node.data)) return; - const { - parentNode, - previousSibling, - nextSibling - } = node; - if (nodeEndsWithNonWhitespace(parentNode.previousSibling) && !this.isBlockElement(parentNode.previousSibling)) return; - if (elementCanDisplayPreformattedText(parentNode)) return; - return !previousSibling || this.isBlockElement(previousSibling) || !nextSibling || this.isBlockElement(nextSibling); - } - isExtraBR(element) { - return tagName(element) === "br" && this.isBlockElement(element.parentNode) && element.parentNode.lastChild === element; - } - needsTableSeparator(element) { - if (parser.removeBlankTableCells) { - var _element$previousSibl; - const content = (_element$previousSibl = element.previousSibling) === null || _element$previousSibl === void 0 ? void 0 : _element$previousSibl.textContent; - return content && /\S/.test(content); - } else { - return element.previousSibling; - } - } - - // Margin translation - - translateBlockElementMarginsToNewlines() { - const defaultMargin = this.getMarginOfDefaultBlockElement(); - for (let index = 0; index < this.blocks.length; index++) { - const margin = this.getMarginOfBlockElementAtIndex(index); - if (margin) { - if (margin.top > defaultMargin.top * 2) { - this.prependStringToTextAtIndex("\n", index); - } - if (margin.bottom > defaultMargin.bottom * 2) { - this.appendStringToTextAtIndex("\n", index); - } - } - } - } - getMarginOfBlockElementAtIndex(index) { - const element = this.blockElements[index]; - if (element) { - if (element.textContent) { - if (!getBlockTagNames().includes(tagName(element)) && !this.processedElements.includes(element)) { - return getBlockElementMargin(element); - } - } - } - } - getMarginOfDefaultBlockElement() { - const element = makeElement(attributes.default.tagName); - this.containerElement.appendChild(element); - return getBlockElementMargin(element); - } - } - - // Helpers - - const elementCanDisplayPreformattedText = function (element) { - const { - whiteSpace - } = window.getComputedStyle(element); - return ["pre", "pre-wrap", "pre-line"].includes(whiteSpace); - }; - const nodeEndsWithNonWhitespace = node => node && !stringEndsWithWhitespace(node.textContent); - const getBlockElementMargin = function (element) { - const style = window.getComputedStyle(element); - if (style.display === "block") { - return { - top: parseInt(style.marginTop), - bottom: parseInt(style.marginBottom) - }; - } - }; - const nodeFilter = function (node) { - if (tagName(node) === "style") { - return NodeFilter.FILTER_REJECT; - } else { - return NodeFilter.FILTER_ACCEPT; - } - }; - - // Whitespace - - const leftTrimBreakableWhitespace = string => string.replace(new RegExp("^".concat(breakableWhitespacePattern.source, "+")), ""); - const stringIsAllBreakableWhitespace = string => new RegExp("^".concat(breakableWhitespacePattern.source, "*$")).test(string); - const stringEndsWithWhitespace = string => /\s$/.test(string); - - /* eslint-disable - no-empty, - */ - const unserializableElementSelector = "[data-trix-serialize=false]"; - const unserializableAttributeNames = ["contenteditable", "data-trix-id", "data-trix-store-key", "data-trix-mutable", "data-trix-placeholder", "tabindex"]; - const serializedAttributesAttribute = "data-trix-serialized-attributes"; - const serializedAttributesSelector = "[".concat(serializedAttributesAttribute, "]"); - const blockCommentPattern = new RegExp("", "g"); - const serializers = { - "application/json": function (serializable) { - let document; - if (serializable instanceof Document) { - document = serializable; - } else if (serializable instanceof HTMLElement) { - document = HTMLParser.parse(serializable.innerHTML).getDocument(); - } else { - throw new Error("unserializable object"); - } - return document.toSerializableDocument().toJSONString(); - }, - "text/html": function (serializable) { - let element; - if (serializable instanceof Document) { - element = DocumentView.render(serializable); - } else if (serializable instanceof HTMLElement) { - element = serializable.cloneNode(true); - } else { - throw new Error("unserializable object"); - } - - // Remove unserializable elements - Array.from(element.querySelectorAll(unserializableElementSelector)).forEach(el => { - removeNode(el); - }); - - // Remove unserializable attributes - unserializableAttributeNames.forEach(attribute => { - Array.from(element.querySelectorAll("[".concat(attribute, "]"))).forEach(el => { - el.removeAttribute(attribute); - }); - }); - - // Rewrite elements with serialized attribute overrides - Array.from(element.querySelectorAll(serializedAttributesSelector)).forEach(el => { - try { - const attributes = JSON.parse(el.getAttribute(serializedAttributesAttribute)); - el.removeAttribute(serializedAttributesAttribute); - for (const name in attributes) { - const value = attributes[name]; - el.setAttribute(name, value); - } - } catch (error) {} - }); - return element.innerHTML.replace(blockCommentPattern, ""); - } - }; - const deserializers = { - "application/json": function (string) { - return Document.fromJSONString(string); - }, - "text/html": function (string) { - return HTMLParser.parse(string).getDocument(); - } - }; - const serializeToContentType = function (serializable, contentType) { - const serializer = serializers[contentType]; - if (serializer) { - return serializer(serializable); - } else { - throw new Error("unknown content type: ".concat(contentType)); - } - }; - const deserializeFromContentType = function (string, contentType) { - const deserializer = deserializers[contentType]; - if (deserializer) { - return deserializer(string); - } else { - throw new Error("unknown content type: ".concat(contentType)); - } - }; - - var core = /*#__PURE__*/Object.freeze({ - __proto__: null - }); - - class ManagedAttachment extends BasicObject { - constructor(attachmentManager, attachment) { - super(...arguments); - this.attachmentManager = attachmentManager; - this.attachment = attachment; - this.id = this.attachment.id; - this.file = this.attachment.file; - } - remove() { - return this.attachmentManager.requestRemovalOfAttachment(this.attachment); - } - } - ManagedAttachment.proxyMethod("attachment.getAttribute"); - ManagedAttachment.proxyMethod("attachment.hasAttribute"); - ManagedAttachment.proxyMethod("attachment.setAttribute"); - ManagedAttachment.proxyMethod("attachment.getAttributes"); - ManagedAttachment.proxyMethod("attachment.setAttributes"); - ManagedAttachment.proxyMethod("attachment.isPending"); - ManagedAttachment.proxyMethod("attachment.isPreviewable"); - ManagedAttachment.proxyMethod("attachment.getURL"); - ManagedAttachment.proxyMethod("attachment.getHref"); - ManagedAttachment.proxyMethod("attachment.getFilename"); - ManagedAttachment.proxyMethod("attachment.getFilesize"); - ManagedAttachment.proxyMethod("attachment.getFormattedFilesize"); - ManagedAttachment.proxyMethod("attachment.getExtension"); - ManagedAttachment.proxyMethod("attachment.getContentType"); - ManagedAttachment.proxyMethod("attachment.getFile"); - ManagedAttachment.proxyMethod("attachment.setFile"); - ManagedAttachment.proxyMethod("attachment.releaseFile"); - ManagedAttachment.proxyMethod("attachment.getUploadProgress"); - ManagedAttachment.proxyMethod("attachment.setUploadProgress"); - - class AttachmentManager extends BasicObject { - constructor() { - let attachments = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : []; - super(...arguments); - this.managedAttachments = {}; - Array.from(attachments).forEach(attachment => { - this.manageAttachment(attachment); - }); - } - getAttachments() { - const result = []; - for (const id in this.managedAttachments) { - const attachment = this.managedAttachments[id]; - result.push(attachment); - } - return result; - } - manageAttachment(attachment) { - if (!this.managedAttachments[attachment.id]) { - this.managedAttachments[attachment.id] = new ManagedAttachment(this, attachment); - } - return this.managedAttachments[attachment.id]; - } - attachmentIsManaged(attachment) { - return attachment.id in this.managedAttachments; - } - requestRemovalOfAttachment(attachment) { - if (this.attachmentIsManaged(attachment)) { - var _this$delegate, _this$delegate$attach; - return (_this$delegate = this.delegate) === null || _this$delegate === void 0 || (_this$delegate$attach = _this$delegate.attachmentManagerDidRequestRemovalOfAttachment) === null || _this$delegate$attach === void 0 ? void 0 : _this$delegate$attach.call(_this$delegate, attachment); - } - } - unmanageAttachment(attachment) { - const managedAttachment = this.managedAttachments[attachment.id]; - delete this.managedAttachments[attachment.id]; - return managedAttachment; - } - } - - class LineBreakInsertion { - constructor(composition) { - this.composition = composition; - this.document = this.composition.document; - const selectedRange = this.composition.getSelectedRange(); - this.startPosition = selectedRange[0]; - this.endPosition = selectedRange[1]; - this.startLocation = this.document.locationFromPosition(this.startPosition); - this.endLocation = this.document.locationFromPosition(this.endPosition); - this.block = this.document.getBlockAtIndex(this.endLocation.index); - this.breaksOnReturn = this.block.breaksOnReturn(); - this.previousCharacter = this.block.text.getStringAtPosition(this.endLocation.offset - 1); - this.nextCharacter = this.block.text.getStringAtPosition(this.endLocation.offset); - } - shouldInsertBlockBreak() { - if (this.block.hasAttributes() && this.block.isListItem() && !this.block.isEmpty()) { - return this.startLocation.offset !== 0; - } else { - return this.breaksOnReturn && this.nextCharacter !== "\n"; - } - } - shouldBreakFormattedBlock() { - return this.block.hasAttributes() && !this.block.isListItem() && (this.breaksOnReturn && this.nextCharacter === "\n" || this.previousCharacter === "\n"); - } - shouldDecreaseListLevel() { - return this.block.hasAttributes() && this.block.isListItem() && this.block.isEmpty(); - } - shouldPrependListItem() { - return this.block.isListItem() && this.startLocation.offset === 0 && !this.block.isEmpty(); - } - shouldRemoveLastBlockAttribute() { - return this.block.hasAttributes() && !this.block.isListItem() && this.block.isEmpty(); - } - } - - const PLACEHOLDER = " "; - class Composition extends BasicObject { - constructor() { - super(...arguments); - this.document = new Document(); - this.attachments = []; - this.currentAttributes = {}; - this.revision = 0; - } - setDocument(document) { - if (!document.isEqualTo(this.document)) { - var _this$delegate, _this$delegate$compos; - this.document = document; - this.refreshAttachments(); - this.revision++; - return (_this$delegate = this.delegate) === null || _this$delegate === void 0 || (_this$delegate$compos = _this$delegate.compositionDidChangeDocument) === null || _this$delegate$compos === void 0 ? void 0 : _this$delegate$compos.call(_this$delegate, document); - } - } - - // Snapshots - - getSnapshot() { - return { - document: this.document, - selectedRange: this.getSelectedRange() - }; - } - loadSnapshot(_ref) { - var _this$delegate2, _this$delegate2$compo, _this$delegate3, _this$delegate3$compo; - let { - document, - selectedRange - } = _ref; - (_this$delegate2 = this.delegate) === null || _this$delegate2 === void 0 || (_this$delegate2$compo = _this$delegate2.compositionWillLoadSnapshot) === null || _this$delegate2$compo === void 0 || _this$delegate2$compo.call(_this$delegate2); - this.setDocument(document != null ? document : new Document()); - this.setSelection(selectedRange != null ? selectedRange : [0, 0]); - return (_this$delegate3 = this.delegate) === null || _this$delegate3 === void 0 || (_this$delegate3$compo = _this$delegate3.compositionDidLoadSnapshot) === null || _this$delegate3$compo === void 0 ? void 0 : _this$delegate3$compo.call(_this$delegate3); - } - - // Responder protocol - - insertText(text) { - let { - updatePosition - } = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : { - updatePosition: true - }; - const selectedRange = this.getSelectedRange(); - this.setDocument(this.document.insertTextAtRange(text, selectedRange)); - const startPosition = selectedRange[0]; - const endPosition = startPosition + text.getLength(); - if (updatePosition) { - this.setSelection(endPosition); - } - return this.notifyDelegateOfInsertionAtRange([startPosition, endPosition]); - } - insertBlock() { - let block = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : new Block(); - const document = new Document([block]); - return this.insertDocument(document); - } - insertDocument() { - let document = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : new Document(); - const selectedRange = this.getSelectedRange(); - this.setDocument(this.document.insertDocumentAtRange(document, selectedRange)); - const startPosition = selectedRange[0]; - const endPosition = startPosition + document.getLength(); - this.setSelection(endPosition); - return this.notifyDelegateOfInsertionAtRange([startPosition, endPosition]); - } - insertString(string, options) { - const attributes = this.getCurrentTextAttributes(); - const text = Text.textForStringWithAttributes(string, attributes); - return this.insertText(text, options); - } - insertBlockBreak() { - const selectedRange = this.getSelectedRange(); - this.setDocument(this.document.insertBlockBreakAtRange(selectedRange)); - const startPosition = selectedRange[0]; - const endPosition = startPosition + 1; - this.setSelection(endPosition); - return this.notifyDelegateOfInsertionAtRange([startPosition, endPosition]); - } - insertLineBreak() { - const insertion = new LineBreakInsertion(this); - if (insertion.shouldDecreaseListLevel()) { - this.decreaseListLevel(); - return this.setSelection(insertion.startPosition); - } else if (insertion.shouldPrependListItem()) { - const document = new Document([insertion.block.copyWithoutText()]); - return this.insertDocument(document); - } else if (insertion.shouldInsertBlockBreak()) { - return this.insertBlockBreak(); - } else if (insertion.shouldRemoveLastBlockAttribute()) { - return this.removeLastBlockAttribute(); - } else if (insertion.shouldBreakFormattedBlock()) { - return this.breakFormattedBlock(insertion); - } else { - return this.insertString("\n"); - } - } - insertHTML(html) { - const document = HTMLParser.parse(html, { - purifyOptions: { - SAFE_FOR_XML: true - } - }).getDocument(); - const selectedRange = this.getSelectedRange(); - this.setDocument(this.document.mergeDocumentAtRange(document, selectedRange)); - const startPosition = selectedRange[0]; - const endPosition = startPosition + document.getLength() - 1; - this.setSelection(endPosition); - return this.notifyDelegateOfInsertionAtRange([startPosition, endPosition]); - } - replaceHTML(html) { - const document = HTMLParser.parse(html).getDocument().copyUsingObjectsFromDocument(this.document); - const locationRange = this.getLocationRange({ - strict: false - }); - const selectedRange = this.document.rangeFromLocationRange(locationRange); - this.setDocument(document); - return this.setSelection(selectedRange); - } - insertFile(file) { - return this.insertFiles([file]); - } - insertFiles(files) { - const attachments = []; - Array.from(files).forEach(file => { - var _this$delegate4; - if ((_this$delegate4 = this.delegate) !== null && _this$delegate4 !== void 0 && _this$delegate4.compositionShouldAcceptFile(file)) { - const attachment = Attachment.attachmentForFile(file); - attachments.push(attachment); - } - }); - return this.insertAttachments(attachments); - } - insertAttachment(attachment) { - return this.insertAttachments([attachment]); - } - insertAttachments(attachments$1) { - let text = new Text(); - Array.from(attachments$1).forEach(attachment => { - var _config$attachments$t; - const type = attachment.getType(); - const presentation = (_config$attachments$t = attachments[type]) === null || _config$attachments$t === void 0 ? void 0 : _config$attachments$t.presentation; - const attributes = this.getCurrentTextAttributes(); - if (presentation) { - attributes.presentation = presentation; - } - const attachmentText = Text.textForAttachmentWithAttributes(attachment, attributes); - text = text.appendText(attachmentText); - }); - return this.insertText(text); - } - shouldManageDeletingInDirection(direction) { - const locationRange = this.getLocationRange(); - if (rangeIsCollapsed(locationRange)) { - if (direction === "backward" && locationRange[0].offset === 0) { - return true; - } - if (this.shouldManageMovingCursorInDirection(direction)) { - return true; - } - } else { - if (locationRange[0].index !== locationRange[1].index) { - return true; - } - } - return false; - } - deleteInDirection(direction) { - let { - length - } = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; - let attachment, deletingIntoPreviousBlock, selectionSpansBlocks; - const locationRange = this.getLocationRange(); - let range = this.getSelectedRange(); - const selectionIsCollapsed = rangeIsCollapsed(range); - if (selectionIsCollapsed) { - deletingIntoPreviousBlock = direction === "backward" && locationRange[0].offset === 0; - } else { - selectionSpansBlocks = locationRange[0].index !== locationRange[1].index; - } - if (deletingIntoPreviousBlock) { - if (this.canDecreaseBlockAttributeLevel()) { - const block = this.getBlock(); - if (block.isListItem()) { - this.decreaseListLevel(); - } else { - this.decreaseBlockAttributeLevel(); - } - this.setSelection(range[0]); - if (block.isEmpty()) { - return false; - } - } - } - if (selectionIsCollapsed) { - range = this.getExpandedRangeInDirection(direction, { - length - }); - if (direction === "backward") { - attachment = this.getAttachmentAtRange(range); - } - } - if (attachment) { - this.editAttachment(attachment); - return false; - } else { - this.setDocument(this.document.removeTextAtRange(range)); - this.setSelection(range[0]); - if (deletingIntoPreviousBlock || selectionSpansBlocks) { - return false; - } - } - } - moveTextFromRange(range) { - const [position] = Array.from(this.getSelectedRange()); - this.setDocument(this.document.moveTextFromRangeToPosition(range, position)); - return this.setSelection(position); - } - removeAttachment(attachment) { - const range = this.document.getRangeOfAttachment(attachment); - if (range) { - this.stopEditingAttachment(); - this.setDocument(this.document.removeTextAtRange(range)); - return this.setSelection(range[0]); - } - } - removeLastBlockAttribute() { - const [startPosition, endPosition] = Array.from(this.getSelectedRange()); - const block = this.document.getBlockAtPosition(endPosition); - this.removeCurrentAttribute(block.getLastAttribute()); - return this.setSelection(startPosition); - } - insertPlaceholder() { - this.placeholderPosition = this.getPosition(); - return this.insertString(PLACEHOLDER); - } - selectPlaceholder() { - if (this.placeholderPosition != null) { - this.setSelectedRange([this.placeholderPosition, this.placeholderPosition + PLACEHOLDER.length]); - return this.getSelectedRange(); - } - } - forgetPlaceholder() { - this.placeholderPosition = null; - } - - // Current attributes - - hasCurrentAttribute(attributeName) { - const value = this.currentAttributes[attributeName]; - return value != null && value !== false; - } - toggleCurrentAttribute(attributeName) { - const value = !this.currentAttributes[attributeName]; - if (value) { - return this.setCurrentAttribute(attributeName, value); - } else { - return this.removeCurrentAttribute(attributeName); - } - } - canSetCurrentAttribute(attributeName) { - if (getBlockConfig(attributeName)) { - return this.canSetCurrentBlockAttribute(attributeName); - } else { - return this.canSetCurrentTextAttribute(attributeName); - } - } - canSetCurrentTextAttribute(attributeName) { - const document = this.getSelectedDocument(); - if (!document) return; - for (const attachment of Array.from(document.getAttachments())) { - if (!attachment.hasContent()) { - return false; - } - } - return true; - } - canSetCurrentBlockAttribute(attributeName) { - const block = this.getBlock(); - if (!block) return; - return !block.isTerminalBlock(); - } - setCurrentAttribute(attributeName, value) { - if (getBlockConfig(attributeName)) { - return this.setBlockAttribute(attributeName, value); - } else { - this.setTextAttribute(attributeName, value); - this.currentAttributes[attributeName] = value; - return this.notifyDelegateOfCurrentAttributesChange(); - } - } - setHTMLAtributeAtPosition(position, attributeName, value) { - var _getBlockConfig; - const block = this.document.getBlockAtPosition(position); - const allowedHTMLAttributes = (_getBlockConfig = getBlockConfig(block.getLastAttribute())) === null || _getBlockConfig === void 0 ? void 0 : _getBlockConfig.htmlAttributes; - if (block && allowedHTMLAttributes !== null && allowedHTMLAttributes !== void 0 && allowedHTMLAttributes.includes(attributeName)) { - const newDocument = this.document.setHTMLAttributeAtPosition(position, attributeName, value); - this.setDocument(newDocument); - } - } - setTextAttribute(attributeName, value) { - const selectedRange = this.getSelectedRange(); - if (!selectedRange) return; - const [startPosition, endPosition] = Array.from(selectedRange); - if (startPosition === endPosition) { - if (attributeName === "href") { - const text = Text.textForStringWithAttributes(value, { - href: value - }); - return this.insertText(text); - } - } else { - return this.setDocument(this.document.addAttributeAtRange(attributeName, value, selectedRange)); - } - } - setBlockAttribute(attributeName, value) { - const selectedRange = this.getSelectedRange(); - if (this.canSetCurrentAttribute(attributeName)) { - this.setDocument(this.document.applyBlockAttributeAtRange(attributeName, value, selectedRange)); - return this.setSelection(selectedRange); - } - } - removeCurrentAttribute(attributeName) { - if (getBlockConfig(attributeName)) { - this.removeBlockAttribute(attributeName); - return this.updateCurrentAttributes(); - } else { - this.removeTextAttribute(attributeName); - delete this.currentAttributes[attributeName]; - return this.notifyDelegateOfCurrentAttributesChange(); - } - } - removeTextAttribute(attributeName) { - const selectedRange = this.getSelectedRange(); - if (!selectedRange) return; - return this.setDocument(this.document.removeAttributeAtRange(attributeName, selectedRange)); - } - removeBlockAttribute(attributeName) { - const selectedRange = this.getSelectedRange(); - if (!selectedRange) return; - return this.setDocument(this.document.removeAttributeAtRange(attributeName, selectedRange)); - } - canDecreaseNestingLevel() { - var _this$getBlock; - return ((_this$getBlock = this.getBlock()) === null || _this$getBlock === void 0 ? void 0 : _this$getBlock.getNestingLevel()) > 0; - } - canIncreaseNestingLevel() { - var _getBlockConfig2; - const block = this.getBlock(); - if (!block) return; - if ((_getBlockConfig2 = getBlockConfig(block.getLastNestableAttribute())) !== null && _getBlockConfig2 !== void 0 && _getBlockConfig2.listAttribute) { - const previousBlock = this.getPreviousBlock(); - if (previousBlock) { - return arrayStartsWith(previousBlock.getListItemAttributes(), block.getListItemAttributes()); - } - } else { - return block.getNestingLevel() > 0; - } - } - decreaseNestingLevel() { - const block = this.getBlock(); - if (!block) return; - return this.setDocument(this.document.replaceBlock(block, block.decreaseNestingLevel())); - } - increaseNestingLevel() { - const block = this.getBlock(); - if (!block) return; - return this.setDocument(this.document.replaceBlock(block, block.increaseNestingLevel())); - } - canDecreaseBlockAttributeLevel() { - var _this$getBlock2; - return ((_this$getBlock2 = this.getBlock()) === null || _this$getBlock2 === void 0 ? void 0 : _this$getBlock2.getAttributeLevel()) > 0; - } - decreaseBlockAttributeLevel() { - var _this$getBlock3; - const attribute = (_this$getBlock3 = this.getBlock()) === null || _this$getBlock3 === void 0 ? void 0 : _this$getBlock3.getLastAttribute(); - if (attribute) { - return this.removeCurrentAttribute(attribute); - } - } - decreaseListLevel() { - let [startPosition] = Array.from(this.getSelectedRange()); - const { - index - } = this.document.locationFromPosition(startPosition); - let endIndex = index; - const attributeLevel = this.getBlock().getAttributeLevel(); - let block = this.document.getBlockAtIndex(endIndex + 1); - while (block) { - if (!block.isListItem() || block.getAttributeLevel() <= attributeLevel) { - break; - } - endIndex++; - block = this.document.getBlockAtIndex(endIndex + 1); - } - startPosition = this.document.positionFromLocation({ - index, - offset: 0 - }); - const endPosition = this.document.positionFromLocation({ - index: endIndex, - offset: 0 - }); - return this.setDocument(this.document.removeLastListAttributeAtRange([startPosition, endPosition])); - } - updateCurrentAttributes() { - const selectedRange = this.getSelectedRange({ - ignoreLock: true - }); - if (selectedRange) { - const currentAttributes = this.document.getCommonAttributesAtRange(selectedRange); - Array.from(getAllAttributeNames()).forEach(attributeName => { - if (!currentAttributes[attributeName]) { - if (!this.canSetCurrentAttribute(attributeName)) { - currentAttributes[attributeName] = false; - } - } - }); - if (!objectsAreEqual(currentAttributes, this.currentAttributes)) { - this.currentAttributes = currentAttributes; - return this.notifyDelegateOfCurrentAttributesChange(); - } - } - } - getCurrentAttributes() { - return extend.call({}, this.currentAttributes); - } - getCurrentTextAttributes() { - const attributes = {}; - for (const key in this.currentAttributes) { - const value = this.currentAttributes[key]; - if (value !== false) { - if (getTextConfig(key)) { - attributes[key] = value; - } - } - } - return attributes; - } - - // Selection freezing - - freezeSelection() { - return this.setCurrentAttribute("frozen", true); - } - thawSelection() { - return this.removeCurrentAttribute("frozen"); - } - hasFrozenSelection() { - return this.hasCurrentAttribute("frozen"); - } - setSelection(selectedRange) { - var _this$delegate5; - const locationRange = this.document.locationRangeFromRange(selectedRange); - return (_this$delegate5 = this.delegate) === null || _this$delegate5 === void 0 ? void 0 : _this$delegate5.compositionDidRequestChangingSelectionToLocationRange(locationRange); - } - getSelectedRange() { - const locationRange = this.getLocationRange(); - if (locationRange) { - return this.document.rangeFromLocationRange(locationRange); - } - } - setSelectedRange(selectedRange) { - const locationRange = this.document.locationRangeFromRange(selectedRange); - return this.getSelectionManager().setLocationRange(locationRange); - } - getPosition() { - const locationRange = this.getLocationRange(); - if (locationRange) { - return this.document.positionFromLocation(locationRange[0]); - } - } - getLocationRange(options) { - if (this.targetLocationRange) { - return this.targetLocationRange; - } else { - return this.getSelectionManager().getLocationRange(options) || normalizeRange({ - index: 0, - offset: 0 - }); - } - } - withTargetLocationRange(locationRange, fn) { - let result; - this.targetLocationRange = locationRange; - try { - result = fn(); - } finally { - this.targetLocationRange = null; - } - return result; - } - withTargetRange(range, fn) { - const locationRange = this.document.locationRangeFromRange(range); - return this.withTargetLocationRange(locationRange, fn); - } - withTargetDOMRange(domRange, fn) { - const locationRange = this.createLocationRangeFromDOMRange(domRange, { - strict: false - }); - return this.withTargetLocationRange(locationRange, fn); - } - getExpandedRangeInDirection(direction) { - let { - length - } = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; - let [startPosition, endPosition] = Array.from(this.getSelectedRange()); - if (direction === "backward") { - if (length) { - startPosition -= length; - } else { - startPosition = this.translateUTF16PositionFromOffset(startPosition, -1); - } - } else { - if (length) { - endPosition += length; - } else { - endPosition = this.translateUTF16PositionFromOffset(endPosition, 1); - } - } - return normalizeRange([startPosition, endPosition]); - } - shouldManageMovingCursorInDirection(direction) { - if (this.editingAttachment) { - return true; - } - const range = this.getExpandedRangeInDirection(direction); - return this.getAttachmentAtRange(range) != null; - } - moveCursorInDirection(direction) { - let canEditAttachment, range; - if (this.editingAttachment) { - range = this.document.getRangeOfAttachment(this.editingAttachment); - } else { - const selectedRange = this.getSelectedRange(); - range = this.getExpandedRangeInDirection(direction); - canEditAttachment = !rangesAreEqual(selectedRange, range); - } - if (direction === "backward") { - this.setSelectedRange(range[0]); - } else { - this.setSelectedRange(range[1]); - } - if (canEditAttachment) { - const attachment = this.getAttachmentAtRange(range); - if (attachment) { - return this.editAttachment(attachment); - } - } - } - expandSelectionInDirection(direction) { - let { - length - } = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; - const range = this.getExpandedRangeInDirection(direction, { - length - }); - return this.setSelectedRange(range); - } - expandSelectionForEditing() { - if (this.hasCurrentAttribute("href")) { - return this.expandSelectionAroundCommonAttribute("href"); - } - } - expandSelectionAroundCommonAttribute(attributeName) { - const position = this.getPosition(); - const range = this.document.getRangeOfCommonAttributeAtPosition(attributeName, position); - return this.setSelectedRange(range); - } - selectionContainsAttachments() { - var _this$getSelectedAtta; - return ((_this$getSelectedAtta = this.getSelectedAttachments()) === null || _this$getSelectedAtta === void 0 ? void 0 : _this$getSelectedAtta.length) > 0; - } - selectionIsInCursorTarget() { - return this.editingAttachment || this.positionIsCursorTarget(this.getPosition()); - } - positionIsCursorTarget(position) { - const location = this.document.locationFromPosition(position); - if (location) { - return this.locationIsCursorTarget(location); - } - } - positionIsBlockBreak(position) { - var _this$document$getPie; - return (_this$document$getPie = this.document.getPieceAtPosition(position)) === null || _this$document$getPie === void 0 ? void 0 : _this$document$getPie.isBlockBreak(); - } - getSelectedDocument() { - const selectedRange = this.getSelectedRange(); - if (selectedRange) { - return this.document.getDocumentAtRange(selectedRange); - } - } - getSelectedAttachments() { - var _this$getSelectedDocu; - return (_this$getSelectedDocu = this.getSelectedDocument()) === null || _this$getSelectedDocu === void 0 ? void 0 : _this$getSelectedDocu.getAttachments(); - } - - // Attachments - - getAttachments() { - return this.attachments.slice(0); - } - refreshAttachments() { - const attachments = this.document.getAttachments(); - const { - added, - removed - } = summarizeArrayChange(this.attachments, attachments); - this.attachments = attachments; - Array.from(removed).forEach(attachment => { - var _this$delegate6, _this$delegate6$compo; - attachment.delegate = null; - (_this$delegate6 = this.delegate) === null || _this$delegate6 === void 0 || (_this$delegate6$compo = _this$delegate6.compositionDidRemoveAttachment) === null || _this$delegate6$compo === void 0 || _this$delegate6$compo.call(_this$delegate6, attachment); - }); - return (() => { - const result = []; - Array.from(added).forEach(attachment => { - var _this$delegate7, _this$delegate7$compo; - attachment.delegate = this; - result.push((_this$delegate7 = this.delegate) === null || _this$delegate7 === void 0 || (_this$delegate7$compo = _this$delegate7.compositionDidAddAttachment) === null || _this$delegate7$compo === void 0 ? void 0 : _this$delegate7$compo.call(_this$delegate7, attachment)); - }); - return result; - })(); - } - - // Attachment delegate - - attachmentDidChangeAttributes(attachment) { - var _this$delegate8, _this$delegate8$compo; - this.revision++; - return (_this$delegate8 = this.delegate) === null || _this$delegate8 === void 0 || (_this$delegate8$compo = _this$delegate8.compositionDidEditAttachment) === null || _this$delegate8$compo === void 0 ? void 0 : _this$delegate8$compo.call(_this$delegate8, attachment); - } - attachmentDidChangePreviewURL(attachment) { - var _this$delegate9, _this$delegate9$compo; - this.revision++; - return (_this$delegate9 = this.delegate) === null || _this$delegate9 === void 0 || (_this$delegate9$compo = _this$delegate9.compositionDidChangeAttachmentPreviewURL) === null || _this$delegate9$compo === void 0 ? void 0 : _this$delegate9$compo.call(_this$delegate9, attachment); - } - - // Attachment editing - - editAttachment(attachment, options) { - var _this$delegate10, _this$delegate10$comp; - if (attachment === this.editingAttachment) return; - this.stopEditingAttachment(); - this.editingAttachment = attachment; - return (_this$delegate10 = this.delegate) === null || _this$delegate10 === void 0 || (_this$delegate10$comp = _this$delegate10.compositionDidStartEditingAttachment) === null || _this$delegate10$comp === void 0 ? void 0 : _this$delegate10$comp.call(_this$delegate10, this.editingAttachment, options); - } - stopEditingAttachment() { - var _this$delegate11, _this$delegate11$comp; - if (!this.editingAttachment) return; - (_this$delegate11 = this.delegate) === null || _this$delegate11 === void 0 || (_this$delegate11$comp = _this$delegate11.compositionDidStopEditingAttachment) === null || _this$delegate11$comp === void 0 || _this$delegate11$comp.call(_this$delegate11, this.editingAttachment); - this.editingAttachment = null; - } - updateAttributesForAttachment(attributes, attachment) { - return this.setDocument(this.document.updateAttributesForAttachment(attributes, attachment)); - } - removeAttributeForAttachment(attribute, attachment) { - return this.setDocument(this.document.removeAttributeForAttachment(attribute, attachment)); - } - - // Private - - breakFormattedBlock(insertion) { - let { - document - } = insertion; - const { - block - } = insertion; - let position = insertion.startPosition; - let range = [position - 1, position]; - if (block.getBlockBreakPosition() === insertion.startLocation.offset) { - if (block.breaksOnReturn() && insertion.nextCharacter === "\n") { - position += 1; - } else { - document = document.removeTextAtRange(range); - } - range = [position, position]; - } else if (insertion.nextCharacter === "\n") { - if (insertion.previousCharacter === "\n") { - range = [position - 1, position + 1]; - } else { - range = [position, position + 1]; - position += 1; - } - } else if (insertion.startLocation.offset - 1 !== 0) { - position += 1; - } - const newDocument = new Document([block.removeLastAttribute().copyWithoutText()]); - this.setDocument(document.insertDocumentAtRange(newDocument, range)); - return this.setSelection(position); - } - getPreviousBlock() { - const locationRange = this.getLocationRange(); - if (locationRange) { - const { - index - } = locationRange[0]; - if (index > 0) { - return this.document.getBlockAtIndex(index - 1); - } - } - } - getBlock() { - const locationRange = this.getLocationRange(); - if (locationRange) { - return this.document.getBlockAtIndex(locationRange[0].index); - } - } - getAttachmentAtRange(range) { - const document = this.document.getDocumentAtRange(range); - if (document.toString() === "".concat(OBJECT_REPLACEMENT_CHARACTER, "\n")) { - return document.getAttachments()[0]; - } - } - notifyDelegateOfCurrentAttributesChange() { - var _this$delegate12, _this$delegate12$comp; - return (_this$delegate12 = this.delegate) === null || _this$delegate12 === void 0 || (_this$delegate12$comp = _this$delegate12.compositionDidChangeCurrentAttributes) === null || _this$delegate12$comp === void 0 ? void 0 : _this$delegate12$comp.call(_this$delegate12, this.currentAttributes); - } - notifyDelegateOfInsertionAtRange(range) { - var _this$delegate13, _this$delegate13$comp; - return (_this$delegate13 = this.delegate) === null || _this$delegate13 === void 0 || (_this$delegate13$comp = _this$delegate13.compositionDidPerformInsertionAtRange) === null || _this$delegate13$comp === void 0 ? void 0 : _this$delegate13$comp.call(_this$delegate13, range); - } - translateUTF16PositionFromOffset(position, offset) { - const utf16string = this.document.toUTF16String(); - const utf16position = utf16string.offsetFromUCS2Offset(position); - return utf16string.offsetToUCS2Offset(utf16position + offset); - } - } - Composition.proxyMethod("getSelectionManager().getPointRange"); - Composition.proxyMethod("getSelectionManager().setLocationRangeFromPointRange"); - Composition.proxyMethod("getSelectionManager().createLocationRangeFromDOMRange"); - Composition.proxyMethod("getSelectionManager().locationIsCursorTarget"); - Composition.proxyMethod("getSelectionManager().selectionIsExpanded"); - Composition.proxyMethod("delegate?.getSelectionManager"); - - class UndoManager extends BasicObject { - constructor(composition) { - super(...arguments); - this.composition = composition; - this.undoEntries = []; - this.redoEntries = []; - } - recordUndoEntry(description) { - let { - context, - consolidatable - } = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; - const previousEntry = this.undoEntries.slice(-1)[0]; - if (!consolidatable || !entryHasDescriptionAndContext(previousEntry, description, context)) { - const undoEntry = this.createEntry({ - description, - context - }); - this.undoEntries.push(undoEntry); - this.redoEntries = []; - } - } - undo() { - const undoEntry = this.undoEntries.pop(); - if (undoEntry) { - const redoEntry = this.createEntry(undoEntry); - this.redoEntries.push(redoEntry); - return this.composition.loadSnapshot(undoEntry.snapshot); - } - } - redo() { - const redoEntry = this.redoEntries.pop(); - if (redoEntry) { - const undoEntry = this.createEntry(redoEntry); - this.undoEntries.push(undoEntry); - return this.composition.loadSnapshot(redoEntry.snapshot); - } - } - canUndo() { - return this.undoEntries.length > 0; - } - canRedo() { - return this.redoEntries.length > 0; - } - - // Private - - createEntry() { - let { - description, - context - } = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; - return { - description: description === null || description === void 0 ? void 0 : description.toString(), - context: JSON.stringify(context), - snapshot: this.composition.getSnapshot() - }; - } - } - const entryHasDescriptionAndContext = (entry, description, context) => (entry === null || entry === void 0 ? void 0 : entry.description) === (description === null || description === void 0 ? void 0 : description.toString()) && (entry === null || entry === void 0 ? void 0 : entry.context) === JSON.stringify(context); - - const BLOCK_ATTRIBUTE_NAME = "attachmentGallery"; - const TEXT_ATTRIBUTE_NAME = "presentation"; - const TEXT_ATTRIBUTE_VALUE = "gallery"; - class Filter { - constructor(snapshot) { - this.document = snapshot.document; - this.selectedRange = snapshot.selectedRange; - } - perform() { - this.removeBlockAttribute(); - return this.applyBlockAttribute(); - } - getSnapshot() { - return { - document: this.document, - selectedRange: this.selectedRange - }; - } - - // Private - - removeBlockAttribute() { - return this.findRangesOfBlocks().map(range => this.document = this.document.removeAttributeAtRange(BLOCK_ATTRIBUTE_NAME, range)); - } - applyBlockAttribute() { - let offset = 0; - this.findRangesOfPieces().forEach(range => { - if (range[1] - range[0] > 1) { - range[0] += offset; - range[1] += offset; - if (this.document.getCharacterAtPosition(range[1]) !== "\n") { - this.document = this.document.insertBlockBreakAtRange(range[1]); - if (range[1] < this.selectedRange[1]) { - this.moveSelectedRangeForward(); - } - range[1]++; - offset++; - } - if (range[0] !== 0) { - if (this.document.getCharacterAtPosition(range[0] - 1) !== "\n") { - this.document = this.document.insertBlockBreakAtRange(range[0]); - if (range[0] < this.selectedRange[0]) { - this.moveSelectedRangeForward(); - } - range[0]++; - offset++; - } - } - this.document = this.document.applyBlockAttributeAtRange(BLOCK_ATTRIBUTE_NAME, true, range); - } - }); - } - findRangesOfBlocks() { - return this.document.findRangesForBlockAttribute(BLOCK_ATTRIBUTE_NAME); - } - findRangesOfPieces() { - return this.document.findRangesForTextAttribute(TEXT_ATTRIBUTE_NAME, { - withValue: TEXT_ATTRIBUTE_VALUE - }); - } - moveSelectedRangeForward() { - this.selectedRange[0] += 1; - this.selectedRange[1] += 1; - } - } - - const attachmentGalleryFilter = function (snapshot) { - const filter = new Filter(snapshot); - filter.perform(); - return filter.getSnapshot(); - }; - - const DEFAULT_FILTERS = [attachmentGalleryFilter]; - class Editor { - constructor(composition, selectionManager, element) { - this.insertFiles = this.insertFiles.bind(this); - this.composition = composition; - this.selectionManager = selectionManager; - this.element = element; - this.undoManager = new UndoManager(this.composition); - this.filters = DEFAULT_FILTERS.slice(0); - } - loadDocument(document) { - return this.loadSnapshot({ - document, - selectedRange: [0, 0] - }); - } - loadHTML() { - let html = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : ""; - const document = HTMLParser.parse(html, { - referenceElement: this.element - }).getDocument(); - return this.loadDocument(document); - } - loadJSON(_ref) { - let { - document, - selectedRange - } = _ref; - document = Document.fromJSON(document); - return this.loadSnapshot({ - document, - selectedRange - }); - } - loadSnapshot(snapshot) { - this.undoManager = new UndoManager(this.composition); - return this.composition.loadSnapshot(snapshot); - } - getDocument() { - return this.composition.document; - } - getSelectedDocument() { - return this.composition.getSelectedDocument(); - } - getSnapshot() { - return this.composition.getSnapshot(); - } - toJSON() { - return this.getSnapshot(); - } - - // Document manipulation - - deleteInDirection(direction) { - return this.composition.deleteInDirection(direction); - } - insertAttachment(attachment) { - return this.composition.insertAttachment(attachment); - } - insertAttachments(attachments) { - return this.composition.insertAttachments(attachments); - } - insertDocument(document) { - return this.composition.insertDocument(document); - } - insertFile(file) { - return this.composition.insertFile(file); - } - insertFiles(files) { - return this.composition.insertFiles(files); - } - insertHTML(html) { - return this.composition.insertHTML(html); - } - insertString(string) { - return this.composition.insertString(string); - } - insertText(text) { - return this.composition.insertText(text); - } - insertLineBreak() { - return this.composition.insertLineBreak(); - } - - // Selection - - getSelectedRange() { - return this.composition.getSelectedRange(); - } - getPosition() { - return this.composition.getPosition(); - } - getClientRectAtPosition(position) { - const locationRange = this.getDocument().locationRangeFromRange([position, position + 1]); - return this.selectionManager.getClientRectAtLocationRange(locationRange); - } - expandSelectionInDirection(direction) { - return this.composition.expandSelectionInDirection(direction); - } - moveCursorInDirection(direction) { - return this.composition.moveCursorInDirection(direction); - } - setSelectedRange(selectedRange) { - return this.composition.setSelectedRange(selectedRange); - } - - // Attributes - - activateAttribute(name) { - let value = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : true; - return this.composition.setCurrentAttribute(name, value); - } - attributeIsActive(name) { - return this.composition.hasCurrentAttribute(name); - } - canActivateAttribute(name) { - return this.composition.canSetCurrentAttribute(name); - } - deactivateAttribute(name) { - return this.composition.removeCurrentAttribute(name); - } - - // HTML attributes - setHTMLAtributeAtPosition(position, name, value) { - this.composition.setHTMLAtributeAtPosition(position, name, value); - } - - // Nesting level - - canDecreaseNestingLevel() { - return this.composition.canDecreaseNestingLevel(); - } - canIncreaseNestingLevel() { - return this.composition.canIncreaseNestingLevel(); - } - decreaseNestingLevel() { - if (this.canDecreaseNestingLevel()) { - return this.composition.decreaseNestingLevel(); - } - } - increaseNestingLevel() { - if (this.canIncreaseNestingLevel()) { - return this.composition.increaseNestingLevel(); - } - } - - // Undo/redo - - canRedo() { - return this.undoManager.canRedo(); - } - canUndo() { - return this.undoManager.canUndo(); - } - recordUndoEntry(description) { - let { - context, - consolidatable - } = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; - return this.undoManager.recordUndoEntry(description, { - context, - consolidatable - }); - } - redo() { - if (this.canRedo()) { - return this.undoManager.redo(); - } - } - undo() { - if (this.canUndo()) { - return this.undoManager.undo(); - } - } - } - - /* eslint-disable - no-var, - prefer-const, - */ - class LocationMapper { - constructor(element) { - this.element = element; - } - findLocationFromContainerAndOffset(container, offset) { - let { - strict - } = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : { - strict: true - }; - let childIndex = 0; - let foundBlock = false; - const location = { - index: 0, - offset: 0 - }; - const attachmentElement = this.findAttachmentElementParentForNode(container); - if (attachmentElement) { - container = attachmentElement.parentNode; - offset = findChildIndexOfNode(attachmentElement); - } - const walker = walkTree(this.element, { - usingFilter: rejectAttachmentContents - }); - while (walker.nextNode()) { - const node = walker.currentNode; - if (node === container && nodeIsTextNode(container)) { - if (!nodeIsCursorTarget(node)) { - location.offset += offset; - } - break; - } else { - if (node.parentNode === container) { - if (childIndex++ === offset) { - break; - } - } else if (!elementContainsNode(container, node)) { - if (childIndex > 0) { - break; - } - } - if (nodeIsBlockStart(node, { - strict - })) { - if (foundBlock) { - location.index++; - } - location.offset = 0; - foundBlock = true; - } else { - location.offset += nodeLength(node); - } - } - } - return location; - } - findContainerAndOffsetFromLocation(location) { - let container, offset; - if (location.index === 0 && location.offset === 0) { - container = this.element; - offset = 0; - while (container.firstChild) { - container = container.firstChild; - if (nodeIsBlockContainer(container)) { - offset = 1; - break; - } - } - return [container, offset]; - } - let [node, nodeOffset] = this.findNodeAndOffsetFromLocation(location); - if (!node) return; - if (nodeIsTextNode(node)) { - if (nodeLength(node) === 0) { - container = node.parentNode.parentNode; - offset = findChildIndexOfNode(node.parentNode); - if (nodeIsCursorTarget(node, { - name: "right" - })) { - offset++; - } - } else { - container = node; - offset = location.offset - nodeOffset; - } - } else { - container = node.parentNode; - if (!nodeIsBlockStart(node.previousSibling)) { - if (!nodeIsBlockContainer(container)) { - while (node === container.lastChild) { - node = container; - container = container.parentNode; - if (nodeIsBlockContainer(container)) { - break; - } - } - } - } - offset = findChildIndexOfNode(node); - if (location.offset !== 0) { - offset++; - } - } - return [container, offset]; - } - findNodeAndOffsetFromLocation(location) { - let node, nodeOffset; - let offset = 0; - for (const currentNode of this.getSignificantNodesForIndex(location.index)) { - const length = nodeLength(currentNode); - if (location.offset <= offset + length) { - if (nodeIsTextNode(currentNode)) { - node = currentNode; - nodeOffset = offset; - if (location.offset === nodeOffset && nodeIsCursorTarget(node)) { - break; - } - } else if (!node) { - node = currentNode; - nodeOffset = offset; - } - } - offset += length; - if (offset > location.offset) { - break; - } - } - return [node, nodeOffset]; - } - - // Private - - findAttachmentElementParentForNode(node) { - while (node && node !== this.element) { - if (nodeIsAttachmentElement(node)) { - return node; - } - node = node.parentNode; - } - } - getSignificantNodesForIndex(index) { - const nodes = []; - const walker = walkTree(this.element, { - usingFilter: acceptSignificantNodes - }); - let recordingNodes = false; - while (walker.nextNode()) { - const node = walker.currentNode; - if (nodeIsBlockStartComment(node)) { - var blockIndex; - if (blockIndex != null) { - blockIndex++; - } else { - blockIndex = 0; - } - if (blockIndex === index) { - recordingNodes = true; - } else if (recordingNodes) { - break; - } - } else if (recordingNodes) { - nodes.push(node); - } - } - return nodes; - } - } - const nodeLength = function (node) { - if (node.nodeType === Node.TEXT_NODE) { - if (nodeIsCursorTarget(node)) { - return 0; - } else { - const string = node.textContent; - return string.length; - } - } else if (tagName(node) === "br" || nodeIsAttachmentElement(node)) { - return 1; - } else { - return 0; - } - }; - const acceptSignificantNodes = function (node) { - if (rejectEmptyTextNodes(node) === NodeFilter.FILTER_ACCEPT) { - return rejectAttachmentContents(node); - } else { - return NodeFilter.FILTER_REJECT; - } - }; - const rejectEmptyTextNodes = function (node) { - if (nodeIsEmptyTextNode(node)) { - return NodeFilter.FILTER_REJECT; - } else { - return NodeFilter.FILTER_ACCEPT; - } - }; - const rejectAttachmentContents = function (node) { - if (nodeIsAttachmentElement(node.parentNode)) { - return NodeFilter.FILTER_REJECT; - } else { - return NodeFilter.FILTER_ACCEPT; - } - }; - - /* eslint-disable - id-length, - no-empty, - */ - class PointMapper { - createDOMRangeFromPoint(_ref) { - let { - x, - y - } = _ref; - let domRange; - if (document.caretPositionFromPoint) { - const { - offsetNode, - offset - } = document.caretPositionFromPoint(x, y); - domRange = document.createRange(); - domRange.setStart(offsetNode, offset); - return domRange; - } else if (document.caretRangeFromPoint) { - return document.caretRangeFromPoint(x, y); - } else if (document.body.createTextRange) { - const originalDOMRange = getDOMRange(); - try { - // IE 11 throws "Unspecified error" when using moveToPoint - // during a drag-and-drop operation. - const textRange = document.body.createTextRange(); - textRange.moveToPoint(x, y); - textRange.select(); - } catch (error) {} - domRange = getDOMRange(); - setDOMRange(originalDOMRange); - return domRange; - } - } - getClientRectsForDOMRange(domRange) { - const array = Array.from(domRange.getClientRects()); - const start = array[0]; - const end = array[array.length - 1]; - return [start, end]; - } - } - - /* eslint-disable - */ - class SelectionManager extends BasicObject { - constructor(element) { - super(...arguments); - this.didMouseDown = this.didMouseDown.bind(this); - this.selectionDidChange = this.selectionDidChange.bind(this); - this.element = element; - this.locationMapper = new LocationMapper(this.element); - this.pointMapper = new PointMapper(); - this.lockCount = 0; - handleEvent("mousedown", { - onElement: this.element, - withCallback: this.didMouseDown - }); - } - getLocationRange() { - let options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; - if (options.strict === false) { - return this.createLocationRangeFromDOMRange(getDOMRange()); - } else if (options.ignoreLock) { - return this.currentLocationRange; - } else if (this.lockedLocationRange) { - return this.lockedLocationRange; - } else { - return this.currentLocationRange; - } - } - setLocationRange(locationRange) { - if (this.lockedLocationRange) return; - locationRange = normalizeRange(locationRange); - const domRange = this.createDOMRangeFromLocationRange(locationRange); - if (domRange) { - setDOMRange(domRange); - this.updateCurrentLocationRange(locationRange); - } - } - setLocationRangeFromPointRange(pointRange) { - pointRange = normalizeRange(pointRange); - const startLocation = this.getLocationAtPoint(pointRange[0]); - const endLocation = this.getLocationAtPoint(pointRange[1]); - this.setLocationRange([startLocation, endLocation]); - } - getClientRectAtLocationRange(locationRange) { - const domRange = this.createDOMRangeFromLocationRange(locationRange); - if (domRange) { - return this.getClientRectsForDOMRange(domRange)[1]; - } - } - locationIsCursorTarget(location) { - const node = Array.from(this.findNodeAndOffsetFromLocation(location))[0]; - return nodeIsCursorTarget(node); - } - lock() { - if (this.lockCount++ === 0) { - this.updateCurrentLocationRange(); - this.lockedLocationRange = this.getLocationRange(); - } - } - unlock() { - if (--this.lockCount === 0) { - const { - lockedLocationRange - } = this; - this.lockedLocationRange = null; - if (lockedLocationRange != null) { - return this.setLocationRange(lockedLocationRange); - } - } - } - clearSelection() { - var _getDOMSelection; - return (_getDOMSelection = getDOMSelection()) === null || _getDOMSelection === void 0 ? void 0 : _getDOMSelection.removeAllRanges(); - } - selectionIsCollapsed() { - var _getDOMRange; - return ((_getDOMRange = getDOMRange()) === null || _getDOMRange === void 0 ? void 0 : _getDOMRange.collapsed) === true; - } - selectionIsExpanded() { - return !this.selectionIsCollapsed(); - } - createLocationRangeFromDOMRange(domRange, options) { - if (domRange == null || !this.domRangeWithinElement(domRange)) return; - const start = this.findLocationFromContainerAndOffset(domRange.startContainer, domRange.startOffset, options); - if (!start) return; - const end = domRange.collapsed ? undefined : this.findLocationFromContainerAndOffset(domRange.endContainer, domRange.endOffset, options); - return normalizeRange([start, end]); - } - didMouseDown() { - return this.pauseTemporarily(); - } - pauseTemporarily() { - let resumeHandlers; - this.paused = true; - const resume = () => { - this.paused = false; - clearTimeout(resumeTimeout); - Array.from(resumeHandlers).forEach(handler => { - handler.destroy(); - }); - if (elementContainsNode(document, this.element)) { - return this.selectionDidChange(); - } - }; - const resumeTimeout = setTimeout(resume, 200); - resumeHandlers = ["mousemove", "keydown"].map(eventName => handleEvent(eventName, { - onElement: document, - withCallback: resume - })); - } - selectionDidChange() { - if (!this.paused && !innerElementIsActive(this.element)) { - return this.updateCurrentLocationRange(); - } - } - updateCurrentLocationRange(locationRange) { - if (locationRange != null ? locationRange : locationRange = this.createLocationRangeFromDOMRange(getDOMRange())) { - if (!rangesAreEqual(locationRange, this.currentLocationRange)) { - var _this$delegate, _this$delegate$locati; - this.currentLocationRange = locationRange; - return (_this$delegate = this.delegate) === null || _this$delegate === void 0 || (_this$delegate$locati = _this$delegate.locationRangeDidChange) === null || _this$delegate$locati === void 0 ? void 0 : _this$delegate$locati.call(_this$delegate, this.currentLocationRange.slice(0)); - } - } - } - createDOMRangeFromLocationRange(locationRange) { - const rangeStart = this.findContainerAndOffsetFromLocation(locationRange[0]); - const rangeEnd = rangeIsCollapsed(locationRange) ? rangeStart : this.findContainerAndOffsetFromLocation(locationRange[1]) || rangeStart; - if (rangeStart != null && rangeEnd != null) { - const domRange = document.createRange(); - domRange.setStart(...Array.from(rangeStart || [])); - domRange.setEnd(...Array.from(rangeEnd || [])); - return domRange; - } - } - getLocationAtPoint(point) { - const domRange = this.createDOMRangeFromPoint(point); - if (domRange) { - var _this$createLocationR; - return (_this$createLocationR = this.createLocationRangeFromDOMRange(domRange)) === null || _this$createLocationR === void 0 ? void 0 : _this$createLocationR[0]; - } - } - domRangeWithinElement(domRange) { - if (domRange.collapsed) { - return elementContainsNode(this.element, domRange.startContainer); - } else { - return elementContainsNode(this.element, domRange.startContainer) && elementContainsNode(this.element, domRange.endContainer); - } - } - } - SelectionManager.proxyMethod("locationMapper.findLocationFromContainerAndOffset"); - SelectionManager.proxyMethod("locationMapper.findContainerAndOffsetFromLocation"); - SelectionManager.proxyMethod("locationMapper.findNodeAndOffsetFromLocation"); - SelectionManager.proxyMethod("pointMapper.createDOMRangeFromPoint"); - SelectionManager.proxyMethod("pointMapper.getClientRectsForDOMRange"); - - var models = /*#__PURE__*/Object.freeze({ - __proto__: null, - Attachment: Attachment, - AttachmentManager: AttachmentManager, - AttachmentPiece: AttachmentPiece, - Block: Block, - Composition: Composition, - Document: Document, - Editor: Editor, - HTMLParser: HTMLParser, - HTMLSanitizer: HTMLSanitizer, - LineBreakInsertion: LineBreakInsertion, - LocationMapper: LocationMapper, - ManagedAttachment: ManagedAttachment, - Piece: Piece, - PointMapper: PointMapper, - SelectionManager: SelectionManager, - SplittableList: SplittableList, - StringPiece: StringPiece, - Text: Text, - UndoManager: UndoManager - }); - - var views = /*#__PURE__*/Object.freeze({ - __proto__: null, - ObjectView: ObjectView, - AttachmentView: AttachmentView, - BlockView: BlockView, - DocumentView: DocumentView, - PieceView: PieceView, - PreviewableAttachmentView: PreviewableAttachmentView, - TextView: TextView - }); - - const { - lang, - css, - keyNames: keyNames$1 - } = config; - const undoable = function (fn) { - return function () { - const commands = fn.apply(this, arguments); - commands.do(); - if (!this.undos) { - this.undos = []; - } - this.undos.push(commands.undo); - }; - }; - class AttachmentEditorController extends BasicObject { - constructor(attachmentPiece, _element, container) { - let options = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : {}; - super(...arguments); - // Installing and uninstalling - _defineProperty(this, "makeElementMutable", undoable(() => { - return { - do: () => { - this.element.dataset.trixMutable = true; - }, - undo: () => delete this.element.dataset.trixMutable - }; - })); - _defineProperty(this, "addToolbar", undoable(() => { - //
- //
- // - // - // - //
- //
- const element = makeElement({ - tagName: "div", - className: css.attachmentToolbar, - data: { - trixMutable: true - }, - childNodes: makeElement({ - tagName: "div", - className: "trix-button-row", - childNodes: makeElement({ - tagName: "span", - className: "trix-button-group trix-button-group--actions", - childNodes: makeElement({ - tagName: "button", - className: "trix-button trix-button--remove", - textContent: lang.remove, - attributes: { - title: lang.remove - }, - data: { - trixAction: "remove" - } - }) - }) - }) - }); - if (this.attachment.isPreviewable()) { - //
- // - // #{name} - // #{size} - // - //
- element.appendChild(makeElement({ - tagName: "div", - className: css.attachmentMetadataContainer, - childNodes: makeElement({ - tagName: "span", - className: css.attachmentMetadata, - childNodes: [makeElement({ - tagName: "span", - className: css.attachmentName, - textContent: this.attachment.getFilename(), - attributes: { - title: this.attachment.getFilename() - } - }), makeElement({ - tagName: "span", - className: css.attachmentSize, - textContent: this.attachment.getFormattedFilesize() - })] - }) - })); - } - handleEvent("click", { - onElement: element, - withCallback: this.didClickToolbar - }); - handleEvent("click", { - onElement: element, - matchingSelector: "[data-trix-action]", - withCallback: this.didClickActionButton - }); - triggerEvent("trix-attachment-before-toolbar", { - onElement: this.element, - attributes: { - toolbar: element, - attachment: this.attachment - } - }); - return { - do: () => this.element.appendChild(element), - undo: () => removeNode(element) - }; - })); - _defineProperty(this, "installCaptionEditor", undoable(() => { - const textarea = makeElement({ - tagName: "textarea", - className: css.attachmentCaptionEditor, - attributes: { - placeholder: lang.captionPlaceholder - }, - data: { - trixMutable: true - } - }); - textarea.value = this.attachmentPiece.getCaption(); - const textareaClone = textarea.cloneNode(); - textareaClone.classList.add("trix-autoresize-clone"); - textareaClone.tabIndex = -1; - const autoresize = function () { - textareaClone.value = textarea.value; - textarea.style.height = textareaClone.scrollHeight + "px"; - }; - handleEvent("input", { - onElement: textarea, - withCallback: autoresize - }); - handleEvent("input", { - onElement: textarea, - withCallback: this.didInputCaption - }); - handleEvent("keydown", { - onElement: textarea, - withCallback: this.didKeyDownCaption - }); - handleEvent("change", { - onElement: textarea, - withCallback: this.didChangeCaption - }); - handleEvent("blur", { - onElement: textarea, - withCallback: this.didBlurCaption - }); - const figcaption = this.element.querySelector("figcaption"); - const editingFigcaption = figcaption.cloneNode(); - return { - do: () => { - figcaption.style.display = "none"; - editingFigcaption.appendChild(textarea); - editingFigcaption.appendChild(textareaClone); - editingFigcaption.classList.add("".concat(css.attachmentCaption, "--editing")); - figcaption.parentElement.insertBefore(editingFigcaption, figcaption); - autoresize(); - if (this.options.editCaption) { - return defer(() => textarea.focus()); - } - }, - undo() { - removeNode(editingFigcaption); - figcaption.style.display = null; - } - }; - })); - this.didClickToolbar = this.didClickToolbar.bind(this); - this.didClickActionButton = this.didClickActionButton.bind(this); - this.didKeyDownCaption = this.didKeyDownCaption.bind(this); - this.didInputCaption = this.didInputCaption.bind(this); - this.didChangeCaption = this.didChangeCaption.bind(this); - this.didBlurCaption = this.didBlurCaption.bind(this); - this.attachmentPiece = attachmentPiece; - this.element = _element; - this.container = container; - this.options = options; - this.attachment = this.attachmentPiece.attachment; - if (tagName(this.element) === "a") { - this.element = this.element.firstChild; - } - this.install(); - } - install() { - this.makeElementMutable(); - this.addToolbar(); - if (this.attachment.isPreviewable()) { - this.installCaptionEditor(); - } - } - uninstall() { - var _this$delegate; - let undo = this.undos.pop(); - this.savePendingCaption(); - while (undo) { - undo(); - undo = this.undos.pop(); - } - (_this$delegate = this.delegate) === null || _this$delegate === void 0 || _this$delegate.didUninstallAttachmentEditor(this); - } - - // Private - - savePendingCaption() { - if (this.pendingCaption != null) { - const caption = this.pendingCaption; - this.pendingCaption = null; - if (caption) { - var _this$delegate2, _this$delegate2$attac; - (_this$delegate2 = this.delegate) === null || _this$delegate2 === void 0 || (_this$delegate2$attac = _this$delegate2.attachmentEditorDidRequestUpdatingAttributesForAttachment) === null || _this$delegate2$attac === void 0 || _this$delegate2$attac.call(_this$delegate2, { - caption - }, this.attachment); - } else { - var _this$delegate3, _this$delegate3$attac; - (_this$delegate3 = this.delegate) === null || _this$delegate3 === void 0 || (_this$delegate3$attac = _this$delegate3.attachmentEditorDidRequestRemovingAttributeForAttachment) === null || _this$delegate3$attac === void 0 || _this$delegate3$attac.call(_this$delegate3, "caption", this.attachment); - } - } - } - // Event handlers - - didClickToolbar(event) { - event.preventDefault(); - return event.stopPropagation(); - } - didClickActionButton(event) { - var _this$delegate4; - const action = event.target.getAttribute("data-trix-action"); - switch (action) { - case "remove": - return (_this$delegate4 = this.delegate) === null || _this$delegate4 === void 0 ? void 0 : _this$delegate4.attachmentEditorDidRequestRemovalOfAttachment(this.attachment); - } - } - didKeyDownCaption(event) { - if (keyNames$1[event.keyCode] === "return") { - var _this$delegate5, _this$delegate5$attac; - event.preventDefault(); - this.savePendingCaption(); - return (_this$delegate5 = this.delegate) === null || _this$delegate5 === void 0 || (_this$delegate5$attac = _this$delegate5.attachmentEditorDidRequestDeselectingAttachment) === null || _this$delegate5$attac === void 0 ? void 0 : _this$delegate5$attac.call(_this$delegate5, this.attachment); - } - } - didInputCaption(event) { - this.pendingCaption = event.target.value.replace(/\s/g, " ").trim(); - } - didChangeCaption(event) { - return this.savePendingCaption(); - } - didBlurCaption(event) { - return this.savePendingCaption(); - } - } - - class CompositionController extends BasicObject { - constructor(element, composition) { - super(...arguments); - this.didFocus = this.didFocus.bind(this); - this.didBlur = this.didBlur.bind(this); - this.didClickAttachment = this.didClickAttachment.bind(this); - this.element = element; - this.composition = composition; - this.documentView = new DocumentView(this.composition.document, { - element: this.element - }); - handleEvent("focus", { - onElement: this.element, - withCallback: this.didFocus - }); - handleEvent("blur", { - onElement: this.element, - withCallback: this.didBlur - }); - handleEvent("click", { - onElement: this.element, - matchingSelector: "a[contenteditable=false]", - preventDefault: true - }); - handleEvent("mousedown", { - onElement: this.element, - matchingSelector: attachmentSelector, - withCallback: this.didClickAttachment - }); - handleEvent("click", { - onElement: this.element, - matchingSelector: "a".concat(attachmentSelector), - preventDefault: true - }); - } - didFocus(event) { - var _this$blurPromise; - const perform = () => { - if (!this.focused) { - var _this$delegate, _this$delegate$compos; - this.focused = true; - return (_this$delegate = this.delegate) === null || _this$delegate === void 0 || (_this$delegate$compos = _this$delegate.compositionControllerDidFocus) === null || _this$delegate$compos === void 0 ? void 0 : _this$delegate$compos.call(_this$delegate); - } - }; - return ((_this$blurPromise = this.blurPromise) === null || _this$blurPromise === void 0 ? void 0 : _this$blurPromise.then(perform)) || perform(); - } - didBlur(event) { - this.blurPromise = new Promise(resolve => { - return defer(() => { - if (!innerElementIsActive(this.element)) { - var _this$delegate2, _this$delegate2$compo; - this.focused = null; - (_this$delegate2 = this.delegate) === null || _this$delegate2 === void 0 || (_this$delegate2$compo = _this$delegate2.compositionControllerDidBlur) === null || _this$delegate2$compo === void 0 || _this$delegate2$compo.call(_this$delegate2); - } - this.blurPromise = null; - return resolve(); - }); - }); - } - didClickAttachment(event, target) { - var _this$delegate3, _this$delegate3$compo; - const attachment = this.findAttachmentForElement(target); - const editCaption = !!findClosestElementFromNode(event.target, { - matchingSelector: "figcaption" - }); - return (_this$delegate3 = this.delegate) === null || _this$delegate3 === void 0 || (_this$delegate3$compo = _this$delegate3.compositionControllerDidSelectAttachment) === null || _this$delegate3$compo === void 0 ? void 0 : _this$delegate3$compo.call(_this$delegate3, attachment, { - editCaption - }); - } - getSerializableElement() { - if (this.isEditingAttachment()) { - return this.documentView.shadowElement; - } else { - return this.element; - } - } - render() { - var _this$delegate6, _this$delegate6$compo; - if (this.revision !== this.composition.revision) { - this.documentView.setDocument(this.composition.document); - this.documentView.render(); - this.revision = this.composition.revision; - } - if (this.canSyncDocumentView() && !this.documentView.isSynced()) { - var _this$delegate4, _this$delegate4$compo, _this$delegate5, _this$delegate5$compo; - (_this$delegate4 = this.delegate) === null || _this$delegate4 === void 0 || (_this$delegate4$compo = _this$delegate4.compositionControllerWillSyncDocumentView) === null || _this$delegate4$compo === void 0 || _this$delegate4$compo.call(_this$delegate4); - this.documentView.sync(); - (_this$delegate5 = this.delegate) === null || _this$delegate5 === void 0 || (_this$delegate5$compo = _this$delegate5.compositionControllerDidSyncDocumentView) === null || _this$delegate5$compo === void 0 || _this$delegate5$compo.call(_this$delegate5); - } - return (_this$delegate6 = this.delegate) === null || _this$delegate6 === void 0 || (_this$delegate6$compo = _this$delegate6.compositionControllerDidRender) === null || _this$delegate6$compo === void 0 ? void 0 : _this$delegate6$compo.call(_this$delegate6); - } - rerenderViewForObject(object) { - this.invalidateViewForObject(object); - return this.render(); - } - invalidateViewForObject(object) { - return this.documentView.invalidateViewForObject(object); - } - isViewCachingEnabled() { - return this.documentView.isViewCachingEnabled(); - } - enableViewCaching() { - return this.documentView.enableViewCaching(); - } - disableViewCaching() { - return this.documentView.disableViewCaching(); - } - refreshViewCache() { - return this.documentView.garbageCollectCachedViews(); - } - - // Attachment editor management - - isEditingAttachment() { - return !!this.attachmentEditor; - } - installAttachmentEditorForAttachment(attachment, options) { - var _this$attachmentEdito; - if (((_this$attachmentEdito = this.attachmentEditor) === null || _this$attachmentEdito === void 0 ? void 0 : _this$attachmentEdito.attachment) === attachment) return; - const element = this.documentView.findElementForObject(attachment); - if (!element) return; - this.uninstallAttachmentEditor(); - const attachmentPiece = this.composition.document.getAttachmentPieceForAttachment(attachment); - this.attachmentEditor = new AttachmentEditorController(attachmentPiece, element, this.element, options); - this.attachmentEditor.delegate = this; - } - uninstallAttachmentEditor() { - var _this$attachmentEdito2; - return (_this$attachmentEdito2 = this.attachmentEditor) === null || _this$attachmentEdito2 === void 0 ? void 0 : _this$attachmentEdito2.uninstall(); - } - - // Attachment controller delegate - - didUninstallAttachmentEditor() { - this.attachmentEditor = null; - return this.render(); - } - attachmentEditorDidRequestUpdatingAttributesForAttachment(attributes, attachment) { - var _this$delegate7, _this$delegate7$compo; - (_this$delegate7 = this.delegate) === null || _this$delegate7 === void 0 || (_this$delegate7$compo = _this$delegate7.compositionControllerWillUpdateAttachment) === null || _this$delegate7$compo === void 0 || _this$delegate7$compo.call(_this$delegate7, attachment); - return this.composition.updateAttributesForAttachment(attributes, attachment); - } - attachmentEditorDidRequestRemovingAttributeForAttachment(attribute, attachment) { - var _this$delegate8, _this$delegate8$compo; - (_this$delegate8 = this.delegate) === null || _this$delegate8 === void 0 || (_this$delegate8$compo = _this$delegate8.compositionControllerWillUpdateAttachment) === null || _this$delegate8$compo === void 0 || _this$delegate8$compo.call(_this$delegate8, attachment); - return this.composition.removeAttributeForAttachment(attribute, attachment); - } - attachmentEditorDidRequestRemovalOfAttachment(attachment) { - var _this$delegate9, _this$delegate9$compo; - return (_this$delegate9 = this.delegate) === null || _this$delegate9 === void 0 || (_this$delegate9$compo = _this$delegate9.compositionControllerDidRequestRemovalOfAttachment) === null || _this$delegate9$compo === void 0 ? void 0 : _this$delegate9$compo.call(_this$delegate9, attachment); - } - attachmentEditorDidRequestDeselectingAttachment(attachment) { - var _this$delegate10, _this$delegate10$comp; - return (_this$delegate10 = this.delegate) === null || _this$delegate10 === void 0 || (_this$delegate10$comp = _this$delegate10.compositionControllerDidRequestDeselectingAttachment) === null || _this$delegate10$comp === void 0 ? void 0 : _this$delegate10$comp.call(_this$delegate10, attachment); - } - - // Private - - canSyncDocumentView() { - return !this.isEditingAttachment(); - } - findAttachmentForElement(element) { - return this.composition.document.getAttachmentById(parseInt(element.dataset.trixId, 10)); - } - } - - class Controller extends BasicObject {} - - const mutableAttributeName = "data-trix-mutable"; - const mutableSelector = "[".concat(mutableAttributeName, "]"); - const options = { - attributes: true, - childList: true, - characterData: true, - characterDataOldValue: true, - subtree: true - }; - class MutationObserver extends BasicObject { - constructor(element) { - super(element); - this.didMutate = this.didMutate.bind(this); - this.element = element; - this.observer = new window.MutationObserver(this.didMutate); - this.start(); - } - start() { - this.reset(); - return this.observer.observe(this.element, options); - } - stop() { - return this.observer.disconnect(); - } - didMutate(mutations) { - this.mutations.push(...Array.from(this.findSignificantMutations(mutations) || [])); - if (this.mutations.length) { - var _this$delegate, _this$delegate$elemen; - (_this$delegate = this.delegate) === null || _this$delegate === void 0 || (_this$delegate$elemen = _this$delegate.elementDidMutate) === null || _this$delegate$elemen === void 0 || _this$delegate$elemen.call(_this$delegate, this.getMutationSummary()); - return this.reset(); - } - } - - // Private - - reset() { - this.mutations = []; - } - findSignificantMutations(mutations) { - return mutations.filter(mutation => { - return this.mutationIsSignificant(mutation); - }); - } - mutationIsSignificant(mutation) { - if (this.nodeIsMutable(mutation.target)) { - return false; - } - for (const node of Array.from(this.nodesModifiedByMutation(mutation))) { - if (this.nodeIsSignificant(node)) return true; - } - return false; - } - nodeIsSignificant(node) { - return node !== this.element && !this.nodeIsMutable(node) && !nodeIsEmptyTextNode(node); - } - nodeIsMutable(node) { - return findClosestElementFromNode(node, { - matchingSelector: mutableSelector - }); - } - nodesModifiedByMutation(mutation) { - const nodes = []; - switch (mutation.type) { - case "attributes": - if (mutation.attributeName !== mutableAttributeName) { - nodes.push(mutation.target); - } - break; - case "characterData": - // Changes to text nodes should consider the parent element - nodes.push(mutation.target.parentNode); - nodes.push(mutation.target); - break; - case "childList": - // Consider each added or removed node - nodes.push(...Array.from(mutation.addedNodes || [])); - nodes.push(...Array.from(mutation.removedNodes || [])); - break; - } - return nodes; - } - getMutationSummary() { - return this.getTextMutationSummary(); - } - getTextMutationSummary() { - const { - additions, - deletions - } = this.getTextChangesFromCharacterData(); - const textChanges = this.getTextChangesFromChildList(); - Array.from(textChanges.additions).forEach(addition => { - if (!Array.from(additions).includes(addition)) { - additions.push(addition); - } - }); - deletions.push(...Array.from(textChanges.deletions || [])); - const summary = {}; - const added = additions.join(""); - if (added) { - summary.textAdded = added; - } - const deleted = deletions.join(""); - if (deleted) { - summary.textDeleted = deleted; - } - return summary; - } - getMutationsByType(type) { - return Array.from(this.mutations).filter(mutation => mutation.type === type); - } - getTextChangesFromChildList() { - let textAdded, textRemoved; - const addedNodes = []; - const removedNodes = []; - Array.from(this.getMutationsByType("childList")).forEach(mutation => { - addedNodes.push(...Array.from(mutation.addedNodes || [])); - removedNodes.push(...Array.from(mutation.removedNodes || [])); - }); - const singleBlockCommentRemoved = addedNodes.length === 0 && removedNodes.length === 1 && nodeIsBlockStartComment(removedNodes[0]); - if (singleBlockCommentRemoved) { - textAdded = []; - textRemoved = ["\n"]; - } else { - textAdded = getTextForNodes(addedNodes); - textRemoved = getTextForNodes(removedNodes); - } - const additions = textAdded.filter((text, index) => text !== textRemoved[index]).map(normalizeSpaces); - const deletions = textRemoved.filter((text, index) => text !== textAdded[index]).map(normalizeSpaces); - return { - additions, - deletions - }; - } - getTextChangesFromCharacterData() { - let added, removed; - const characterMutations = this.getMutationsByType("characterData"); - if (characterMutations.length) { - const startMutation = characterMutations[0], - endMutation = characterMutations[characterMutations.length - 1]; - const oldString = normalizeSpaces(startMutation.oldValue); - const newString = normalizeSpaces(endMutation.target.data); - const summarized = summarizeStringChange(oldString, newString); - added = summarized.added; - removed = summarized.removed; - } - return { - additions: added ? [added] : [], - deletions: removed ? [removed] : [] - }; - } - } - const getTextForNodes = function () { - let nodes = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : []; - const text = []; - for (const node of Array.from(nodes)) { - switch (node.nodeType) { - case Node.TEXT_NODE: - text.push(node.data); - break; - case Node.ELEMENT_NODE: - if (tagName(node) === "br") { - text.push("\n"); - } else { - text.push(...Array.from(getTextForNodes(node.childNodes) || [])); - } - break; - } - } - return text; - }; - - /* eslint-disable - no-empty, - */ - class FileVerificationOperation extends Operation { - constructor(file) { - super(...arguments); - this.file = file; - } - perform(callback) { - const reader = new FileReader(); - reader.onerror = () => callback(false); - reader.onload = () => { - reader.onerror = null; - try { - reader.abort(); - } catch (error) {} - return callback(true, this.file); - }; - return reader.readAsArrayBuffer(this.file); - } - } - - // Each software keyboard on Android emits its own set of events and some of them can be buggy. - // This class detects when some buggy events are being emitted and lets know the input controller - // that they should be ignored. - class FlakyAndroidKeyboardDetector { - constructor(element) { - this.element = element; - } - shouldIgnore(event) { - if (!browser$1.samsungAndroid) return false; - this.previousEvent = this.event; - this.event = event; - this.checkSamsungKeyboardBuggyModeStart(); - this.checkSamsungKeyboardBuggyModeEnd(); - return this.buggyMode; - } - - // private - - // The Samsung keyboard on Android can enter a buggy state in which it emits a flurry of confused events that, - // if processed, corrupts the editor. The buggy mode always starts with an insertText event, right after a - // keydown event with for an "Unidentified" key, with the same text as the editor element, except for a few - // extra whitespace, or exotic utf8, characters. - checkSamsungKeyboardBuggyModeStart() { - if (this.insertingLongTextAfterUnidentifiedChar() && differsInWhitespace(this.element.innerText, this.event.data)) { - this.buggyMode = true; - this.event.preventDefault(); - } - } - - // The flurry of buggy events are always insertText. If we see any other type, it means it's over. - checkSamsungKeyboardBuggyModeEnd() { - if (this.buggyMode && this.event.inputType !== "insertText") { - this.buggyMode = false; - } - } - insertingLongTextAfterUnidentifiedChar() { - var _this$event$data; - return this.isBeforeInputInsertText() && this.previousEventWasUnidentifiedKeydown() && ((_this$event$data = this.event.data) === null || _this$event$data === void 0 ? void 0 : _this$event$data.length) > 50; - } - isBeforeInputInsertText() { - return this.event.type === "beforeinput" && this.event.inputType === "insertText"; - } - previousEventWasUnidentifiedKeydown() { - var _this$previousEvent, _this$previousEvent2; - return ((_this$previousEvent = this.previousEvent) === null || _this$previousEvent === void 0 ? void 0 : _this$previousEvent.type) === "keydown" && ((_this$previousEvent2 = this.previousEvent) === null || _this$previousEvent2 === void 0 ? void 0 : _this$previousEvent2.key) === "Unidentified"; - } - } - const differsInWhitespace = (text1, text2) => { - return normalize(text1) === normalize(text2); - }; - const whiteSpaceNormalizerRegexp = new RegExp("(".concat(OBJECT_REPLACEMENT_CHARACTER, "|").concat(ZERO_WIDTH_SPACE, "|").concat(NON_BREAKING_SPACE, "|\\s)+"), "g"); - const normalize = text => text.replace(whiteSpaceNormalizerRegexp, " ").trim(); - - class InputController extends BasicObject { - constructor(element) { - super(...arguments); - this.element = element; - this.mutationObserver = new MutationObserver(this.element); - this.mutationObserver.delegate = this; - this.flakyKeyboardDetector = new FlakyAndroidKeyboardDetector(this.element); - for (const eventName in this.constructor.events) { - handleEvent(eventName, { - onElement: this.element, - withCallback: this.handlerFor(eventName) - }); - } - } - elementDidMutate(mutationSummary) {} - editorWillSyncDocumentView() { - return this.mutationObserver.stop(); - } - editorDidSyncDocumentView() { - return this.mutationObserver.start(); - } - requestRender() { - var _this$delegate, _this$delegate$inputC; - return (_this$delegate = this.delegate) === null || _this$delegate === void 0 || (_this$delegate$inputC = _this$delegate.inputControllerDidRequestRender) === null || _this$delegate$inputC === void 0 ? void 0 : _this$delegate$inputC.call(_this$delegate); - } - requestReparse() { - var _this$delegate2, _this$delegate2$input; - (_this$delegate2 = this.delegate) === null || _this$delegate2 === void 0 || (_this$delegate2$input = _this$delegate2.inputControllerDidRequestReparse) === null || _this$delegate2$input === void 0 || _this$delegate2$input.call(_this$delegate2); - return this.requestRender(); - } - attachFiles(files) { - const operations = Array.from(files).map(file => new FileVerificationOperation(file)); - return Promise.all(operations).then(files => { - this.handleInput(function () { - var _this$delegate3, _this$responder; - (_this$delegate3 = this.delegate) === null || _this$delegate3 === void 0 || _this$delegate3.inputControllerWillAttachFiles(); - (_this$responder = this.responder) === null || _this$responder === void 0 || _this$responder.insertFiles(files); - return this.requestRender(); - }); - }); - } - - // Private - - handlerFor(eventName) { - return event => { - if (!event.defaultPrevented) { - this.handleInput(() => { - if (!innerElementIsActive(this.element)) { - if (this.flakyKeyboardDetector.shouldIgnore(event)) return; - this.eventName = eventName; - this.constructor.events[eventName].call(this, event); - } - }); - } - }; - } - handleInput(callback) { - try { - var _this$delegate4; - (_this$delegate4 = this.delegate) === null || _this$delegate4 === void 0 || _this$delegate4.inputControllerWillHandleInput(); - callback.call(this); - } finally { - var _this$delegate5; - (_this$delegate5 = this.delegate) === null || _this$delegate5 === void 0 || _this$delegate5.inputControllerDidHandleInput(); - } - } - createLinkHTML(href, text) { - const link = document.createElement("a"); - link.href = href; - link.textContent = text ? text : href; - return link.outerHTML; - } - } - _defineProperty(InputController, "events", {}); - - var _$codePointAt, _; - const { - browser, - keyNames - } = config; - let pastedFileCount = 0; - class Level0InputController extends InputController { - constructor() { - super(...arguments); - this.resetInputSummary(); - } - setInputSummary() { - let summary = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; - this.inputSummary.eventName = this.eventName; - for (const key in summary) { - const value = summary[key]; - this.inputSummary[key] = value; - } - return this.inputSummary; - } - resetInputSummary() { - this.inputSummary = {}; - } - reset() { - this.resetInputSummary(); - return selectionChangeObserver.reset(); - } - - // Mutation observer delegate - - elementDidMutate(mutationSummary) { - if (this.isComposing()) { - var _this$delegate, _this$delegate$inputC; - return (_this$delegate = this.delegate) === null || _this$delegate === void 0 || (_this$delegate$inputC = _this$delegate.inputControllerDidAllowUnhandledInput) === null || _this$delegate$inputC === void 0 ? void 0 : _this$delegate$inputC.call(_this$delegate); - } else { - return this.handleInput(function () { - if (this.mutationIsSignificant(mutationSummary)) { - if (this.mutationIsExpected(mutationSummary)) { - this.requestRender(); - } else { - this.requestReparse(); - } - } - return this.reset(); - }); - } - } - mutationIsExpected(_ref) { - let { - textAdded, - textDeleted - } = _ref; - if (this.inputSummary.preferDocument) { - return true; - } - const mutationAdditionMatchesSummary = textAdded != null ? textAdded === this.inputSummary.textAdded : !this.inputSummary.textAdded; - const mutationDeletionMatchesSummary = textDeleted != null ? this.inputSummary.didDelete : !this.inputSummary.didDelete; - const unexpectedNewlineAddition = ["\n", " \n"].includes(textAdded) && !mutationAdditionMatchesSummary; - const unexpectedNewlineDeletion = textDeleted === "\n" && !mutationDeletionMatchesSummary; - const singleUnexpectedNewline = unexpectedNewlineAddition && !unexpectedNewlineDeletion || unexpectedNewlineDeletion && !unexpectedNewlineAddition; - if (singleUnexpectedNewline) { - const range = this.getSelectedRange(); - if (range) { - var _this$responder; - const offset = unexpectedNewlineAddition ? textAdded.replace(/\n$/, "").length || -1 : (textAdded === null || textAdded === void 0 ? void 0 : textAdded.length) || 1; - if ((_this$responder = this.responder) !== null && _this$responder !== void 0 && _this$responder.positionIsBlockBreak(range[1] + offset)) { - return true; - } - } - } - return mutationAdditionMatchesSummary && mutationDeletionMatchesSummary; - } - mutationIsSignificant(mutationSummary) { - var _this$compositionInpu; - const textChanged = Object.keys(mutationSummary).length > 0; - const composedEmptyString = ((_this$compositionInpu = this.compositionInput) === null || _this$compositionInpu === void 0 ? void 0 : _this$compositionInpu.getEndData()) === ""; - return textChanged || !composedEmptyString; - } - - // Private - - getCompositionInput() { - if (this.isComposing()) { - return this.compositionInput; - } else { - this.compositionInput = new CompositionInput(this); - } - } - isComposing() { - return this.compositionInput && !this.compositionInput.isEnded(); - } - deleteInDirection(direction, event) { - var _this$responder2; - if (((_this$responder2 = this.responder) === null || _this$responder2 === void 0 ? void 0 : _this$responder2.deleteInDirection(direction)) === false) { - if (event) { - event.preventDefault(); - return this.requestRender(); - } - } else { - return this.setInputSummary({ - didDelete: true - }); - } - } - serializeSelectionToDataTransfer(dataTransfer) { - var _this$responder3; - if (!dataTransferIsWritable(dataTransfer)) return; - const document = (_this$responder3 = this.responder) === null || _this$responder3 === void 0 ? void 0 : _this$responder3.getSelectedDocument().toSerializableDocument(); - dataTransfer.setData("application/x-trix-document", JSON.stringify(document)); - dataTransfer.setData("text/html", DocumentView.render(document).innerHTML); - dataTransfer.setData("text/plain", document.toString().replace(/\n$/, "")); - return true; - } - canAcceptDataTransfer(dataTransfer) { - const types = {}; - Array.from((dataTransfer === null || dataTransfer === void 0 ? void 0 : dataTransfer.types) || []).forEach(type => { - types[type] = true; - }); - return types.Files || types["application/x-trix-document"] || types["text/html"] || types["text/plain"]; - } - getPastedHTMLUsingHiddenElement(callback) { - const selectedRange = this.getSelectedRange(); - const style = { - position: "absolute", - left: "".concat(window.pageXOffset, "px"), - top: "".concat(window.pageYOffset, "px"), - opacity: 0 - }; - const element = makeElement({ - style, - tagName: "div", - editable: true - }); - document.body.appendChild(element); - element.focus(); - return requestAnimationFrame(() => { - const html = element.innerHTML; - removeNode(element); - this.setSelectedRange(selectedRange); - return callback(html); - }); - } - } - _defineProperty(Level0InputController, "events", { - keydown(event) { - if (!this.isComposing()) { - this.resetInputSummary(); - } - this.inputSummary.didInput = true; - const keyName = keyNames[event.keyCode]; - if (keyName) { - var _context2; - let context = this.keys; - ["ctrl", "alt", "shift", "meta"].forEach(modifier => { - if (event["".concat(modifier, "Key")]) { - var _context; - if (modifier === "ctrl") { - modifier = "control"; - } - context = (_context = context) === null || _context === void 0 ? void 0 : _context[modifier]; - } - }); - if (((_context2 = context) === null || _context2 === void 0 ? void 0 : _context2[keyName]) != null) { - this.setInputSummary({ - keyName - }); - selectionChangeObserver.reset(); - context[keyName].call(this, event); - } - } - if (keyEventIsKeyboardCommand(event)) { - const character = String.fromCharCode(event.keyCode).toLowerCase(); - if (character) { - var _this$delegate3; - const keys = ["alt", "shift"].map(modifier => { - if (event["".concat(modifier, "Key")]) { - return modifier; - } - }).filter(key => key); - keys.push(character); - if ((_this$delegate3 = this.delegate) !== null && _this$delegate3 !== void 0 && _this$delegate3.inputControllerDidReceiveKeyboardCommand(keys)) { - event.preventDefault(); - } - } - } - }, - keypress(event) { - if (this.inputSummary.eventName != null) return; - if (event.metaKey) return; - if (event.ctrlKey && !event.altKey) return; - const string = stringFromKeyEvent(event); - if (string) { - var _this$delegate4, _this$responder9; - (_this$delegate4 = this.delegate) === null || _this$delegate4 === void 0 || _this$delegate4.inputControllerWillPerformTyping(); - (_this$responder9 = this.responder) === null || _this$responder9 === void 0 || _this$responder9.insertString(string); - return this.setInputSummary({ - textAdded: string, - didDelete: this.selectionIsExpanded() - }); - } - }, - textInput(event) { - // Handle autocapitalization - const { - data - } = event; - const { - textAdded - } = this.inputSummary; - if (textAdded && textAdded !== data && textAdded.toUpperCase() === data) { - var _this$responder10; - const range = this.getSelectedRange(); - this.setSelectedRange([range[0], range[1] + textAdded.length]); - (_this$responder10 = this.responder) === null || _this$responder10 === void 0 || _this$responder10.insertString(data); - this.setInputSummary({ - textAdded: data - }); - return this.setSelectedRange(range); - } - }, - dragenter(event) { - event.preventDefault(); - }, - dragstart(event) { - var _this$delegate5, _this$delegate5$input; - this.serializeSelectionToDataTransfer(event.dataTransfer); - this.draggedRange = this.getSelectedRange(); - return (_this$delegate5 = this.delegate) === null || _this$delegate5 === void 0 || (_this$delegate5$input = _this$delegate5.inputControllerDidStartDrag) === null || _this$delegate5$input === void 0 ? void 0 : _this$delegate5$input.call(_this$delegate5); - }, - dragover(event) { - if (this.draggedRange || this.canAcceptDataTransfer(event.dataTransfer)) { - event.preventDefault(); - const draggingPoint = { - x: event.clientX, - y: event.clientY - }; - if (!objectsAreEqual(draggingPoint, this.draggingPoint)) { - var _this$delegate6, _this$delegate6$input; - this.draggingPoint = draggingPoint; - return (_this$delegate6 = this.delegate) === null || _this$delegate6 === void 0 || (_this$delegate6$input = _this$delegate6.inputControllerDidReceiveDragOverPoint) === null || _this$delegate6$input === void 0 ? void 0 : _this$delegate6$input.call(_this$delegate6, this.draggingPoint); - } - } - }, - dragend(event) { - var _this$delegate7, _this$delegate7$input; - (_this$delegate7 = this.delegate) === null || _this$delegate7 === void 0 || (_this$delegate7$input = _this$delegate7.inputControllerDidCancelDrag) === null || _this$delegate7$input === void 0 || _this$delegate7$input.call(_this$delegate7); - this.draggedRange = null; - this.draggingPoint = null; - }, - drop(event) { - var _event$dataTransfer, _this$responder11; - event.preventDefault(); - const files = (_event$dataTransfer = event.dataTransfer) === null || _event$dataTransfer === void 0 ? void 0 : _event$dataTransfer.files; - const documentJSON = event.dataTransfer.getData("application/x-trix-document"); - const point = { - x: event.clientX, - y: event.clientY - }; - (_this$responder11 = this.responder) === null || _this$responder11 === void 0 || _this$responder11.setLocationRangeFromPointRange(point); - if (files !== null && files !== void 0 && files.length) { - this.attachFiles(files); - } else if (this.draggedRange) { - var _this$delegate8, _this$responder12; - (_this$delegate8 = this.delegate) === null || _this$delegate8 === void 0 || _this$delegate8.inputControllerWillMoveText(); - (_this$responder12 = this.responder) === null || _this$responder12 === void 0 || _this$responder12.moveTextFromRange(this.draggedRange); - this.draggedRange = null; - this.requestRender(); - } else if (documentJSON) { - var _this$responder13; - const document = Document.fromJSONString(documentJSON); - (_this$responder13 = this.responder) === null || _this$responder13 === void 0 || _this$responder13.insertDocument(document); - this.requestRender(); - } - this.draggedRange = null; - this.draggingPoint = null; - }, - cut(event) { - var _this$responder14; - if ((_this$responder14 = this.responder) !== null && _this$responder14 !== void 0 && _this$responder14.selectionIsExpanded()) { - var _this$delegate9; - if (this.serializeSelectionToDataTransfer(event.clipboardData)) { - event.preventDefault(); - } - (_this$delegate9 = this.delegate) === null || _this$delegate9 === void 0 || _this$delegate9.inputControllerWillCutText(); - this.deleteInDirection("backward"); - if (event.defaultPrevented) { - return this.requestRender(); - } - } - }, - copy(event) { - var _this$responder15; - if ((_this$responder15 = this.responder) !== null && _this$responder15 !== void 0 && _this$responder15.selectionIsExpanded()) { - if (this.serializeSelectionToDataTransfer(event.clipboardData)) { - event.preventDefault(); - } - } - }, - paste(event) { - const clipboard = event.clipboardData || event.testClipboardData; - const paste = { - clipboard - }; - if (!clipboard || pasteEventIsCrippledSafariHTMLPaste(event)) { - this.getPastedHTMLUsingHiddenElement(html => { - var _this$delegate10, _this$responder16, _this$delegate11; - paste.type = "text/html"; - paste.html = html; - (_this$delegate10 = this.delegate) === null || _this$delegate10 === void 0 || _this$delegate10.inputControllerWillPaste(paste); - (_this$responder16 = this.responder) === null || _this$responder16 === void 0 || _this$responder16.insertHTML(paste.html); - this.requestRender(); - return (_this$delegate11 = this.delegate) === null || _this$delegate11 === void 0 ? void 0 : _this$delegate11.inputControllerDidPaste(paste); - }); - return; - } - const href = clipboard.getData("URL"); - const html = clipboard.getData("text/html"); - const name = clipboard.getData("public.url-name"); - if (href) { - var _this$delegate12, _this$responder17, _this$delegate13; - let string; - paste.type = "text/html"; - if (name) { - string = squishBreakableWhitespace(name).trim(); - } else { - string = href; - } - paste.html = this.createLinkHTML(href, string); - (_this$delegate12 = this.delegate) === null || _this$delegate12 === void 0 || _this$delegate12.inputControllerWillPaste(paste); - this.setInputSummary({ - textAdded: string, - didDelete: this.selectionIsExpanded() - }); - (_this$responder17 = this.responder) === null || _this$responder17 === void 0 || _this$responder17.insertHTML(paste.html); - this.requestRender(); - (_this$delegate13 = this.delegate) === null || _this$delegate13 === void 0 || _this$delegate13.inputControllerDidPaste(paste); - } else if (dataTransferIsPlainText(clipboard)) { - var _this$delegate14, _this$responder18, _this$delegate15; - paste.type = "text/plain"; - paste.string = clipboard.getData("text/plain"); - (_this$delegate14 = this.delegate) === null || _this$delegate14 === void 0 || _this$delegate14.inputControllerWillPaste(paste); - this.setInputSummary({ - textAdded: paste.string, - didDelete: this.selectionIsExpanded() - }); - (_this$responder18 = this.responder) === null || _this$responder18 === void 0 || _this$responder18.insertString(paste.string); - this.requestRender(); - (_this$delegate15 = this.delegate) === null || _this$delegate15 === void 0 || _this$delegate15.inputControllerDidPaste(paste); - } else if (html) { - var _this$delegate16, _this$responder19, _this$delegate17; - paste.type = "text/html"; - paste.html = html; - (_this$delegate16 = this.delegate) === null || _this$delegate16 === void 0 || _this$delegate16.inputControllerWillPaste(paste); - (_this$responder19 = this.responder) === null || _this$responder19 === void 0 || _this$responder19.insertHTML(paste.html); - this.requestRender(); - (_this$delegate17 = this.delegate) === null || _this$delegate17 === void 0 || _this$delegate17.inputControllerDidPaste(paste); - } else if (Array.from(clipboard.types).includes("Files")) { - var _clipboard$items, _clipboard$items$getA; - const file = (_clipboard$items = clipboard.items) === null || _clipboard$items === void 0 || (_clipboard$items = _clipboard$items[0]) === null || _clipboard$items === void 0 || (_clipboard$items$getA = _clipboard$items.getAsFile) === null || _clipboard$items$getA === void 0 ? void 0 : _clipboard$items$getA.call(_clipboard$items); - if (file) { - var _this$delegate18, _this$responder20, _this$delegate19; - const extension = extensionForFile(file); - if (!file.name && extension) { - file.name = "pasted-file-".concat(++pastedFileCount, ".").concat(extension); - } - paste.type = "File"; - paste.file = file; - (_this$delegate18 = this.delegate) === null || _this$delegate18 === void 0 || _this$delegate18.inputControllerWillAttachFiles(); - (_this$responder20 = this.responder) === null || _this$responder20 === void 0 || _this$responder20.insertFile(paste.file); - this.requestRender(); - (_this$delegate19 = this.delegate) === null || _this$delegate19 === void 0 || _this$delegate19.inputControllerDidPaste(paste); - } - } - event.preventDefault(); - }, - compositionstart(event) { - return this.getCompositionInput().start(event.data); - }, - compositionupdate(event) { - return this.getCompositionInput().update(event.data); - }, - compositionend(event) { - return this.getCompositionInput().end(event.data); - }, - beforeinput(event) { - this.inputSummary.didInput = true; - }, - input(event) { - this.inputSummary.didInput = true; - return event.stopPropagation(); - } - }); - _defineProperty(Level0InputController, "keys", { - backspace(event) { - var _this$delegate20; - (_this$delegate20 = this.delegate) === null || _this$delegate20 === void 0 || _this$delegate20.inputControllerWillPerformTyping(); - return this.deleteInDirection("backward", event); - }, - delete(event) { - var _this$delegate21; - (_this$delegate21 = this.delegate) === null || _this$delegate21 === void 0 || _this$delegate21.inputControllerWillPerformTyping(); - return this.deleteInDirection("forward", event); - }, - return(event) { - var _this$delegate22, _this$responder21; - this.setInputSummary({ - preferDocument: true - }); - (_this$delegate22 = this.delegate) === null || _this$delegate22 === void 0 || _this$delegate22.inputControllerWillPerformTyping(); - return (_this$responder21 = this.responder) === null || _this$responder21 === void 0 ? void 0 : _this$responder21.insertLineBreak(); - }, - tab(event) { - var _this$responder22; - if ((_this$responder22 = this.responder) !== null && _this$responder22 !== void 0 && _this$responder22.canIncreaseNestingLevel()) { - var _this$responder23; - (_this$responder23 = this.responder) === null || _this$responder23 === void 0 || _this$responder23.increaseNestingLevel(); - this.requestRender(); - event.preventDefault(); - } - }, - left(event) { - if (this.selectionIsInCursorTarget()) { - var _this$responder24; - event.preventDefault(); - return (_this$responder24 = this.responder) === null || _this$responder24 === void 0 ? void 0 : _this$responder24.moveCursorInDirection("backward"); - } - }, - right(event) { - if (this.selectionIsInCursorTarget()) { - var _this$responder25; - event.preventDefault(); - return (_this$responder25 = this.responder) === null || _this$responder25 === void 0 ? void 0 : _this$responder25.moveCursorInDirection("forward"); - } - }, - control: { - d(event) { - var _this$delegate23; - (_this$delegate23 = this.delegate) === null || _this$delegate23 === void 0 || _this$delegate23.inputControllerWillPerformTyping(); - return this.deleteInDirection("forward", event); - }, - h(event) { - var _this$delegate24; - (_this$delegate24 = this.delegate) === null || _this$delegate24 === void 0 || _this$delegate24.inputControllerWillPerformTyping(); - return this.deleteInDirection("backward", event); - }, - o(event) { - var _this$delegate25, _this$responder26; - event.preventDefault(); - (_this$delegate25 = this.delegate) === null || _this$delegate25 === void 0 || _this$delegate25.inputControllerWillPerformTyping(); - (_this$responder26 = this.responder) === null || _this$responder26 === void 0 || _this$responder26.insertString("\n", { - updatePosition: false - }); - return this.requestRender(); - } - }, - shift: { - return(event) { - var _this$delegate26, _this$responder27; - (_this$delegate26 = this.delegate) === null || _this$delegate26 === void 0 || _this$delegate26.inputControllerWillPerformTyping(); - (_this$responder27 = this.responder) === null || _this$responder27 === void 0 || _this$responder27.insertString("\n"); - this.requestRender(); - event.preventDefault(); - }, - tab(event) { - var _this$responder28; - if ((_this$responder28 = this.responder) !== null && _this$responder28 !== void 0 && _this$responder28.canDecreaseNestingLevel()) { - var _this$responder29; - (_this$responder29 = this.responder) === null || _this$responder29 === void 0 || _this$responder29.decreaseNestingLevel(); - this.requestRender(); - event.preventDefault(); - } - }, - left(event) { - if (this.selectionIsInCursorTarget()) { - event.preventDefault(); - return this.expandSelectionInDirection("backward"); - } - }, - right(event) { - if (this.selectionIsInCursorTarget()) { - event.preventDefault(); - return this.expandSelectionInDirection("forward"); - } - } - }, - alt: { - backspace(event) { - var _this$delegate27; - this.setInputSummary({ - preferDocument: false - }); - return (_this$delegate27 = this.delegate) === null || _this$delegate27 === void 0 ? void 0 : _this$delegate27.inputControllerWillPerformTyping(); - } - }, - meta: { - backspace(event) { - var _this$delegate28; - this.setInputSummary({ - preferDocument: false - }); - return (_this$delegate28 = this.delegate) === null || _this$delegate28 === void 0 ? void 0 : _this$delegate28.inputControllerWillPerformTyping(); - } - } - }); - Level0InputController.proxyMethod("responder?.getSelectedRange"); - Level0InputController.proxyMethod("responder?.setSelectedRange"); - Level0InputController.proxyMethod("responder?.expandSelectionInDirection"); - Level0InputController.proxyMethod("responder?.selectionIsInCursorTarget"); - Level0InputController.proxyMethod("responder?.selectionIsExpanded"); - const extensionForFile = file => { - var _file$type; - return (_file$type = file.type) === null || _file$type === void 0 || (_file$type = _file$type.match(/\/(\w+)$/)) === null || _file$type === void 0 ? void 0 : _file$type[1]; - }; - const hasStringCodePointAt = !!((_$codePointAt = (_ = " ").codePointAt) !== null && _$codePointAt !== void 0 && _$codePointAt.call(_, 0)); - const stringFromKeyEvent = function (event) { - if (event.key && hasStringCodePointAt && event.key.codePointAt(0) === event.keyCode) { - return event.key; - } else { - let code; - if (event.which === null) { - code = event.keyCode; - } else if (event.which !== 0 && event.charCode !== 0) { - code = event.charCode; - } - if (code != null && keyNames[code] !== "escape") { - return UTF16String.fromCodepoints([code]).toString(); - } - } - }; - const pasteEventIsCrippledSafariHTMLPaste = function (event) { - const paste = event.clipboardData; - if (paste) { - if (paste.types.includes("text/html")) { - // Answer is yes if there's any possibility of Paste and Match Style in Safari, - // which is nearly impossible to detect confidently: https://bugs.webkit.org/show_bug.cgi?id=174165 - for (const type of paste.types) { - const hasPasteboardFlavor = /^CorePasteboardFlavorType/.test(type); - const hasReadableDynamicData = /^dyn\./.test(type) && paste.getData(type); - const mightBePasteAndMatchStyle = hasPasteboardFlavor || hasReadableDynamicData; - if (mightBePasteAndMatchStyle) { - return true; - } - } - return false; - } else { - const isExternalHTMLPaste = paste.types.includes("com.apple.webarchive"); - const isExternalRichTextPaste = paste.types.includes("com.apple.flat-rtfd"); - return isExternalHTMLPaste || isExternalRichTextPaste; - } - } - }; - class CompositionInput extends BasicObject { - constructor(inputController) { - super(...arguments); - this.inputController = inputController; - this.responder = this.inputController.responder; - this.delegate = this.inputController.delegate; - this.inputSummary = this.inputController.inputSummary; - this.data = {}; - } - start(data) { - this.data.start = data; - if (this.isSignificant()) { - var _this$responder5; - if (this.inputSummary.eventName === "keypress" && this.inputSummary.textAdded) { - var _this$responder4; - (_this$responder4 = this.responder) === null || _this$responder4 === void 0 || _this$responder4.deleteInDirection("left"); - } - if (!this.selectionIsExpanded()) { - this.insertPlaceholder(); - this.requestRender(); - } - this.range = (_this$responder5 = this.responder) === null || _this$responder5 === void 0 ? void 0 : _this$responder5.getSelectedRange(); - } - } - update(data) { - this.data.update = data; - if (this.isSignificant()) { - const range = this.selectPlaceholder(); - if (range) { - this.forgetPlaceholder(); - this.range = range; - } - } - } - end(data) { - this.data.end = data; - if (this.isSignificant()) { - this.forgetPlaceholder(); - if (this.canApplyToDocument()) { - var _this$delegate2, _this$responder6, _this$responder7, _this$responder8; - this.setInputSummary({ - preferDocument: true, - didInput: false - }); - (_this$delegate2 = this.delegate) === null || _this$delegate2 === void 0 || _this$delegate2.inputControllerWillPerformTyping(); - (_this$responder6 = this.responder) === null || _this$responder6 === void 0 || _this$responder6.setSelectedRange(this.range); - (_this$responder7 = this.responder) === null || _this$responder7 === void 0 || _this$responder7.insertString(this.data.end); - return (_this$responder8 = this.responder) === null || _this$responder8 === void 0 ? void 0 : _this$responder8.setSelectedRange(this.range[0] + this.data.end.length); - } else if (this.data.start != null || this.data.update != null) { - this.requestReparse(); - return this.inputController.reset(); - } - } else { - return this.inputController.reset(); - } - } - getEndData() { - return this.data.end; - } - isEnded() { - return this.getEndData() != null; - } - isSignificant() { - if (browser.composesExistingText) { - return this.inputSummary.didInput; - } else { - return true; - } - } - - // Private - - canApplyToDocument() { - var _this$data$start, _this$data$end; - return ((_this$data$start = this.data.start) === null || _this$data$start === void 0 ? void 0 : _this$data$start.length) === 0 && ((_this$data$end = this.data.end) === null || _this$data$end === void 0 ? void 0 : _this$data$end.length) > 0 && this.range; - } - } - CompositionInput.proxyMethod("inputController.setInputSummary"); - CompositionInput.proxyMethod("inputController.requestRender"); - CompositionInput.proxyMethod("inputController.requestReparse"); - CompositionInput.proxyMethod("responder?.selectionIsExpanded"); - CompositionInput.proxyMethod("responder?.insertPlaceholder"); - CompositionInput.proxyMethod("responder?.selectPlaceholder"); - CompositionInput.proxyMethod("responder?.forgetPlaceholder"); - - class Level2InputController extends InputController { - constructor() { - super(...arguments); - this.render = this.render.bind(this); - } - elementDidMutate() { - if (this.scheduledRender) { - if (this.composing) { - var _this$delegate, _this$delegate$inputC; - return (_this$delegate = this.delegate) === null || _this$delegate === void 0 || (_this$delegate$inputC = _this$delegate.inputControllerDidAllowUnhandledInput) === null || _this$delegate$inputC === void 0 ? void 0 : _this$delegate$inputC.call(_this$delegate); - } - } else { - return this.reparse(); - } - } - scheduleRender() { - return this.scheduledRender ? this.scheduledRender : this.scheduledRender = requestAnimationFrame(this.render); - } - render() { - var _this$afterRender; - cancelAnimationFrame(this.scheduledRender); - this.scheduledRender = null; - if (!this.composing) { - var _this$delegate2; - (_this$delegate2 = this.delegate) === null || _this$delegate2 === void 0 || _this$delegate2.render(); - } - (_this$afterRender = this.afterRender) === null || _this$afterRender === void 0 || _this$afterRender.call(this); - this.afterRender = null; - } - reparse() { - var _this$delegate3; - return (_this$delegate3 = this.delegate) === null || _this$delegate3 === void 0 ? void 0 : _this$delegate3.reparse(); - } - - // Responder helpers - - insertString() { - var _this$delegate4; - let string = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : ""; - let options = arguments.length > 1 ? arguments[1] : undefined; - (_this$delegate4 = this.delegate) === null || _this$delegate4 === void 0 || _this$delegate4.inputControllerWillPerformTyping(); - return this.withTargetDOMRange(function () { - var _this$responder; - return (_this$responder = this.responder) === null || _this$responder === void 0 ? void 0 : _this$responder.insertString(string, options); - }); - } - toggleAttributeIfSupported(attributeName) { - if (getAllAttributeNames().includes(attributeName)) { - var _this$delegate5; - (_this$delegate5 = this.delegate) === null || _this$delegate5 === void 0 || _this$delegate5.inputControllerWillPerformFormatting(attributeName); - return this.withTargetDOMRange(function () { - var _this$responder2; - return (_this$responder2 = this.responder) === null || _this$responder2 === void 0 ? void 0 : _this$responder2.toggleCurrentAttribute(attributeName); - }); - } - } - activateAttributeIfSupported(attributeName, value) { - if (getAllAttributeNames().includes(attributeName)) { - var _this$delegate6; - (_this$delegate6 = this.delegate) === null || _this$delegate6 === void 0 || _this$delegate6.inputControllerWillPerformFormatting(attributeName); - return this.withTargetDOMRange(function () { - var _this$responder3; - return (_this$responder3 = this.responder) === null || _this$responder3 === void 0 ? void 0 : _this$responder3.setCurrentAttribute(attributeName, value); - }); - } - } - deleteInDirection(direction) { - let { - recordUndoEntry - } = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : { - recordUndoEntry: true - }; - if (recordUndoEntry) { - var _this$delegate7; - (_this$delegate7 = this.delegate) === null || _this$delegate7 === void 0 || _this$delegate7.inputControllerWillPerformTyping(); - } - const perform = () => { - var _this$responder4; - return (_this$responder4 = this.responder) === null || _this$responder4 === void 0 ? void 0 : _this$responder4.deleteInDirection(direction); - }; - const domRange = this.getTargetDOMRange({ - minLength: this.composing ? 1 : 2 - }); - if (domRange) { - return this.withTargetDOMRange(domRange, perform); - } else { - return perform(); - } - } - - // Selection helpers - - withTargetDOMRange(domRange, fn) { - if (typeof domRange === "function") { - fn = domRange; - domRange = this.getTargetDOMRange(); - } - if (domRange) { - var _this$responder5; - return (_this$responder5 = this.responder) === null || _this$responder5 === void 0 ? void 0 : _this$responder5.withTargetDOMRange(domRange, fn.bind(this)); - } else { - selectionChangeObserver.reset(); - return fn.call(this); - } - } - getTargetDOMRange() { - var _this$event$getTarget, _this$event; - let { - minLength - } = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : { - minLength: 0 - }; - const targetRanges = (_this$event$getTarget = (_this$event = this.event).getTargetRanges) === null || _this$event$getTarget === void 0 ? void 0 : _this$event$getTarget.call(_this$event); - if (targetRanges) { - if (targetRanges.length) { - const domRange = staticRangeToRange(targetRanges[0]); - if (minLength === 0 || domRange.toString().length >= minLength) { - return domRange; - } - } - } - } - withEvent(event, fn) { - let result; - this.event = event; - try { - result = fn.call(this); - } finally { - this.event = null; - } - return result; - } - } - _defineProperty(Level2InputController, "events", { - keydown(event) { - if (keyEventIsKeyboardCommand(event)) { - var _this$delegate8; - const command = keyboardCommandFromKeyEvent(event); - if ((_this$delegate8 = this.delegate) !== null && _this$delegate8 !== void 0 && _this$delegate8.inputControllerDidReceiveKeyboardCommand(command)) { - event.preventDefault(); - } - } else { - let name = event.key; - if (event.altKey) { - name += "+Alt"; - } - if (event.shiftKey) { - name += "+Shift"; - } - const handler = this.constructor.keys[name]; - if (handler) { - return this.withEvent(event, handler); - } - } - }, - // Handle paste event to work around beforeinput.insertFromPaste browser bugs. - // Safe to remove each condition once fixed upstream. - paste(event) { - var _event$clipboardData; - // https://bugs.webkit.org/show_bug.cgi?id=194921 - let paste; - const href = (_event$clipboardData = event.clipboardData) === null || _event$clipboardData === void 0 ? void 0 : _event$clipboardData.getData("URL"); - if (pasteEventHasFilesOnly(event)) { - event.preventDefault(); - return this.attachFiles(event.clipboardData.files); - - // https://bugs.chromium.org/p/chromium/issues/detail?id=934448 - } else if (pasteEventHasPlainTextOnly(event)) { - var _this$delegate9, _this$responder6, _this$delegate10; - event.preventDefault(); - paste = { - type: "text/plain", - string: event.clipboardData.getData("text/plain") - }; - (_this$delegate9 = this.delegate) === null || _this$delegate9 === void 0 || _this$delegate9.inputControllerWillPaste(paste); - (_this$responder6 = this.responder) === null || _this$responder6 === void 0 || _this$responder6.insertString(paste.string); - this.render(); - return (_this$delegate10 = this.delegate) === null || _this$delegate10 === void 0 ? void 0 : _this$delegate10.inputControllerDidPaste(paste); - - // https://bugs.webkit.org/show_bug.cgi?id=196702 - } else if (href) { - var _this$delegate11, _this$responder7, _this$delegate12; - event.preventDefault(); - paste = { - type: "text/html", - html: this.createLinkHTML(href) - }; - (_this$delegate11 = this.delegate) === null || _this$delegate11 === void 0 || _this$delegate11.inputControllerWillPaste(paste); - (_this$responder7 = this.responder) === null || _this$responder7 === void 0 || _this$responder7.insertHTML(paste.html); - this.render(); - return (_this$delegate12 = this.delegate) === null || _this$delegate12 === void 0 ? void 0 : _this$delegate12.inputControllerDidPaste(paste); - } - }, - beforeinput(event) { - const handler = this.constructor.inputTypes[event.inputType]; - const immmediateRender = shouldRenderInmmediatelyToDealWithIOSDictation(event); - if (handler) { - this.withEvent(event, handler); - if (!immmediateRender) { - this.scheduleRender(); - } - } - if (immmediateRender) { - this.render(); - } - }, - input(event) { - selectionChangeObserver.reset(); - }, - dragstart(event) { - var _this$responder8; - if ((_this$responder8 = this.responder) !== null && _this$responder8 !== void 0 && _this$responder8.selectionContainsAttachments()) { - var _this$responder9; - event.dataTransfer.setData("application/x-trix-dragging", true); - this.dragging = { - range: (_this$responder9 = this.responder) === null || _this$responder9 === void 0 ? void 0 : _this$responder9.getSelectedRange(), - point: pointFromEvent(event) - }; - } - }, - dragenter(event) { - if (dragEventHasFiles(event)) { - event.preventDefault(); - } - }, - dragover(event) { - if (this.dragging) { - event.preventDefault(); - const point = pointFromEvent(event); - if (!objectsAreEqual(point, this.dragging.point)) { - var _this$responder10; - this.dragging.point = point; - return (_this$responder10 = this.responder) === null || _this$responder10 === void 0 ? void 0 : _this$responder10.setLocationRangeFromPointRange(point); - } - } else if (dragEventHasFiles(event)) { - event.preventDefault(); - } - }, - drop(event) { - if (this.dragging) { - var _this$delegate13, _this$responder11; - event.preventDefault(); - (_this$delegate13 = this.delegate) === null || _this$delegate13 === void 0 || _this$delegate13.inputControllerWillMoveText(); - (_this$responder11 = this.responder) === null || _this$responder11 === void 0 || _this$responder11.moveTextFromRange(this.dragging.range); - this.dragging = null; - return this.scheduleRender(); - } else if (dragEventHasFiles(event)) { - var _this$responder12; - event.preventDefault(); - const point = pointFromEvent(event); - (_this$responder12 = this.responder) === null || _this$responder12 === void 0 || _this$responder12.setLocationRangeFromPointRange(point); - return this.attachFiles(event.dataTransfer.files); - } - }, - dragend() { - if (this.dragging) { - var _this$responder13; - (_this$responder13 = this.responder) === null || _this$responder13 === void 0 || _this$responder13.setSelectedRange(this.dragging.range); - this.dragging = null; - } - }, - compositionend(event) { - if (this.composing) { - this.composing = false; - if (!browser$1.recentAndroid) this.scheduleRender(); - } - } - }); - _defineProperty(Level2InputController, "keys", { - ArrowLeft() { - var _this$responder14; - if ((_this$responder14 = this.responder) !== null && _this$responder14 !== void 0 && _this$responder14.shouldManageMovingCursorInDirection("backward")) { - var _this$responder15; - this.event.preventDefault(); - return (_this$responder15 = this.responder) === null || _this$responder15 === void 0 ? void 0 : _this$responder15.moveCursorInDirection("backward"); - } - }, - ArrowRight() { - var _this$responder16; - if ((_this$responder16 = this.responder) !== null && _this$responder16 !== void 0 && _this$responder16.shouldManageMovingCursorInDirection("forward")) { - var _this$responder17; - this.event.preventDefault(); - return (_this$responder17 = this.responder) === null || _this$responder17 === void 0 ? void 0 : _this$responder17.moveCursorInDirection("forward"); - } - }, - Backspace() { - var _this$responder18; - if ((_this$responder18 = this.responder) !== null && _this$responder18 !== void 0 && _this$responder18.shouldManageDeletingInDirection("backward")) { - var _this$delegate14, _this$responder19; - this.event.preventDefault(); - (_this$delegate14 = this.delegate) === null || _this$delegate14 === void 0 || _this$delegate14.inputControllerWillPerformTyping(); - (_this$responder19 = this.responder) === null || _this$responder19 === void 0 || _this$responder19.deleteInDirection("backward"); - return this.render(); - } - }, - Tab() { - var _this$responder20; - if ((_this$responder20 = this.responder) !== null && _this$responder20 !== void 0 && _this$responder20.canIncreaseNestingLevel()) { - var _this$responder21; - this.event.preventDefault(); - (_this$responder21 = this.responder) === null || _this$responder21 === void 0 || _this$responder21.increaseNestingLevel(); - return this.render(); - } - }, - "Tab+Shift"() { - var _this$responder22; - if ((_this$responder22 = this.responder) !== null && _this$responder22 !== void 0 && _this$responder22.canDecreaseNestingLevel()) { - var _this$responder23; - this.event.preventDefault(); - (_this$responder23 = this.responder) === null || _this$responder23 === void 0 || _this$responder23.decreaseNestingLevel(); - return this.render(); - } - } - }); - _defineProperty(Level2InputController, "inputTypes", { - deleteByComposition() { - return this.deleteInDirection("backward", { - recordUndoEntry: false - }); - }, - deleteByCut() { - return this.deleteInDirection("backward"); - }, - deleteByDrag() { - this.event.preventDefault(); - return this.withTargetDOMRange(function () { - var _this$responder24; - this.deleteByDragRange = (_this$responder24 = this.responder) === null || _this$responder24 === void 0 ? void 0 : _this$responder24.getSelectedRange(); - }); - }, - deleteCompositionText() { - return this.deleteInDirection("backward", { - recordUndoEntry: false - }); - }, - deleteContent() { - return this.deleteInDirection("backward"); - }, - deleteContentBackward() { - return this.deleteInDirection("backward"); - }, - deleteContentForward() { - return this.deleteInDirection("forward"); - }, - deleteEntireSoftLine() { - return this.deleteInDirection("forward"); - }, - deleteHardLineBackward() { - return this.deleteInDirection("backward"); - }, - deleteHardLineForward() { - return this.deleteInDirection("forward"); - }, - deleteSoftLineBackward() { - return this.deleteInDirection("backward"); - }, - deleteSoftLineForward() { - return this.deleteInDirection("forward"); - }, - deleteWordBackward() { - return this.deleteInDirection("backward"); - }, - deleteWordForward() { - return this.deleteInDirection("forward"); - }, - formatBackColor() { - return this.activateAttributeIfSupported("backgroundColor", this.event.data); - }, - formatBold() { - return this.toggleAttributeIfSupported("bold"); - }, - formatFontColor() { - return this.activateAttributeIfSupported("color", this.event.data); - }, - formatFontName() { - return this.activateAttributeIfSupported("font", this.event.data); - }, - formatIndent() { - var _this$responder25; - if ((_this$responder25 = this.responder) !== null && _this$responder25 !== void 0 && _this$responder25.canIncreaseNestingLevel()) { - return this.withTargetDOMRange(function () { - var _this$responder26; - return (_this$responder26 = this.responder) === null || _this$responder26 === void 0 ? void 0 : _this$responder26.increaseNestingLevel(); - }); - } - }, - formatItalic() { - return this.toggleAttributeIfSupported("italic"); - }, - formatJustifyCenter() { - return this.toggleAttributeIfSupported("justifyCenter"); - }, - formatJustifyFull() { - return this.toggleAttributeIfSupported("justifyFull"); - }, - formatJustifyLeft() { - return this.toggleAttributeIfSupported("justifyLeft"); - }, - formatJustifyRight() { - return this.toggleAttributeIfSupported("justifyRight"); - }, - formatOutdent() { - var _this$responder27; - if ((_this$responder27 = this.responder) !== null && _this$responder27 !== void 0 && _this$responder27.canDecreaseNestingLevel()) { - return this.withTargetDOMRange(function () { - var _this$responder28; - return (_this$responder28 = this.responder) === null || _this$responder28 === void 0 ? void 0 : _this$responder28.decreaseNestingLevel(); - }); - } - }, - formatRemove() { - this.withTargetDOMRange(function () { - for (const attributeName in (_this$responder29 = this.responder) === null || _this$responder29 === void 0 ? void 0 : _this$responder29.getCurrentAttributes()) { - var _this$responder29, _this$responder30; - (_this$responder30 = this.responder) === null || _this$responder30 === void 0 || _this$responder30.removeCurrentAttribute(attributeName); - } - }); - }, - formatSetBlockTextDirection() { - return this.activateAttributeIfSupported("blockDir", this.event.data); - }, - formatSetInlineTextDirection() { - return this.activateAttributeIfSupported("textDir", this.event.data); - }, - formatStrikeThrough() { - return this.toggleAttributeIfSupported("strike"); - }, - formatSubscript() { - return this.toggleAttributeIfSupported("sub"); - }, - formatSuperscript() { - return this.toggleAttributeIfSupported("sup"); - }, - formatUnderline() { - return this.toggleAttributeIfSupported("underline"); - }, - historyRedo() { - var _this$delegate15; - return (_this$delegate15 = this.delegate) === null || _this$delegate15 === void 0 ? void 0 : _this$delegate15.inputControllerWillPerformRedo(); - }, - historyUndo() { - var _this$delegate16; - return (_this$delegate16 = this.delegate) === null || _this$delegate16 === void 0 ? void 0 : _this$delegate16.inputControllerWillPerformUndo(); - }, - insertCompositionText() { - this.composing = true; - return this.insertString(this.event.data); - }, - insertFromComposition() { - this.composing = false; - return this.insertString(this.event.data); - }, - insertFromDrop() { - const range = this.deleteByDragRange; - if (range) { - var _this$delegate17; - this.deleteByDragRange = null; - (_this$delegate17 = this.delegate) === null || _this$delegate17 === void 0 || _this$delegate17.inputControllerWillMoveText(); - return this.withTargetDOMRange(function () { - var _this$responder31; - return (_this$responder31 = this.responder) === null || _this$responder31 === void 0 ? void 0 : _this$responder31.moveTextFromRange(range); - }); - } - }, - insertFromPaste() { - const { - dataTransfer - } = this.event; - const paste = { - dataTransfer - }; - const href = dataTransfer.getData("URL"); - const html = dataTransfer.getData("text/html"); - if (href) { - var _this$delegate18; - let string; - this.event.preventDefault(); - paste.type = "text/html"; - const name = dataTransfer.getData("public.url-name"); - if (name) { - string = squishBreakableWhitespace(name).trim(); - } else { - string = href; - } - paste.html = this.createLinkHTML(href, string); - (_this$delegate18 = this.delegate) === null || _this$delegate18 === void 0 || _this$delegate18.inputControllerWillPaste(paste); - this.withTargetDOMRange(function () { - var _this$responder32; - return (_this$responder32 = this.responder) === null || _this$responder32 === void 0 ? void 0 : _this$responder32.insertHTML(paste.html); - }); - this.afterRender = () => { - var _this$delegate19; - return (_this$delegate19 = this.delegate) === null || _this$delegate19 === void 0 ? void 0 : _this$delegate19.inputControllerDidPaste(paste); - }; - } else if (dataTransferIsPlainText(dataTransfer)) { - var _this$delegate20; - paste.type = "text/plain"; - paste.string = dataTransfer.getData("text/plain"); - (_this$delegate20 = this.delegate) === null || _this$delegate20 === void 0 || _this$delegate20.inputControllerWillPaste(paste); - this.withTargetDOMRange(function () { - var _this$responder33; - return (_this$responder33 = this.responder) === null || _this$responder33 === void 0 ? void 0 : _this$responder33.insertString(paste.string); - }); - this.afterRender = () => { - var _this$delegate21; - return (_this$delegate21 = this.delegate) === null || _this$delegate21 === void 0 ? void 0 : _this$delegate21.inputControllerDidPaste(paste); - }; - } else if (processableFilePaste(this.event)) { - var _this$delegate22; - paste.type = "File"; - paste.file = dataTransfer.files[0]; - (_this$delegate22 = this.delegate) === null || _this$delegate22 === void 0 || _this$delegate22.inputControllerWillPaste(paste); - this.withTargetDOMRange(function () { - var _this$responder34; - return (_this$responder34 = this.responder) === null || _this$responder34 === void 0 ? void 0 : _this$responder34.insertFile(paste.file); - }); - this.afterRender = () => { - var _this$delegate23; - return (_this$delegate23 = this.delegate) === null || _this$delegate23 === void 0 ? void 0 : _this$delegate23.inputControllerDidPaste(paste); - }; - } else if (html) { - var _this$delegate24; - this.event.preventDefault(); - paste.type = "text/html"; - paste.html = html; - (_this$delegate24 = this.delegate) === null || _this$delegate24 === void 0 || _this$delegate24.inputControllerWillPaste(paste); - this.withTargetDOMRange(function () { - var _this$responder35; - return (_this$responder35 = this.responder) === null || _this$responder35 === void 0 ? void 0 : _this$responder35.insertHTML(paste.html); - }); - this.afterRender = () => { - var _this$delegate25; - return (_this$delegate25 = this.delegate) === null || _this$delegate25 === void 0 ? void 0 : _this$delegate25.inputControllerDidPaste(paste); - }; - } - }, - insertFromYank() { - return this.insertString(this.event.data); - }, - insertLineBreak() { - return this.insertString("\n"); - }, - insertLink() { - return this.activateAttributeIfSupported("href", this.event.data); - }, - insertOrderedList() { - return this.toggleAttributeIfSupported("number"); - }, - insertParagraph() { - var _this$delegate26; - (_this$delegate26 = this.delegate) === null || _this$delegate26 === void 0 || _this$delegate26.inputControllerWillPerformTyping(); - return this.withTargetDOMRange(function () { - var _this$responder36; - return (_this$responder36 = this.responder) === null || _this$responder36 === void 0 ? void 0 : _this$responder36.insertLineBreak(); - }); - }, - insertReplacementText() { - const replacement = this.event.dataTransfer.getData("text/plain"); - const domRange = this.event.getTargetRanges()[0]; - this.withTargetDOMRange(domRange, () => { - this.insertString(replacement, { - updatePosition: false - }); - }); - }, - insertText() { - var _this$event$dataTrans; - return this.insertString(this.event.data || ((_this$event$dataTrans = this.event.dataTransfer) === null || _this$event$dataTrans === void 0 ? void 0 : _this$event$dataTrans.getData("text/plain"))); - }, - insertTranspose() { - return this.insertString(this.event.data); - }, - insertUnorderedList() { - return this.toggleAttributeIfSupported("bullet"); - } - }); - const staticRangeToRange = function (staticRange) { - const range = document.createRange(); - range.setStart(staticRange.startContainer, staticRange.startOffset); - range.setEnd(staticRange.endContainer, staticRange.endOffset); - return range; - }; - - // Event helpers - - const dragEventHasFiles = event => { - var _event$dataTransfer; - return Array.from(((_event$dataTransfer = event.dataTransfer) === null || _event$dataTransfer === void 0 ? void 0 : _event$dataTransfer.types) || []).includes("Files"); - }; - const processableFilePaste = event => { - var _event$dataTransfer$f; - // Paste events that only have files are handled by the paste event handler, - // to work around Safari not supporting beforeinput.insertFromPaste for files. - - // MS Office text pastes include a file with a screenshot of the text, but we should - // handle them as text pastes. - return ((_event$dataTransfer$f = event.dataTransfer.files) === null || _event$dataTransfer$f === void 0 ? void 0 : _event$dataTransfer$f[0]) && !pasteEventHasFilesOnly(event) && !dataTransferIsMsOfficePaste(event); - }; - const pasteEventHasFilesOnly = function (event) { - const clipboard = event.clipboardData; - if (clipboard) { - const fileTypes = Array.from(clipboard.types).filter(type => type.match(/file/i)); // "Files", "application/x-moz-file" - return fileTypes.length === clipboard.types.length && clipboard.files.length >= 1; - } - }; - const pasteEventHasPlainTextOnly = function (event) { - const clipboard = event.clipboardData; - if (clipboard) { - return clipboard.types.includes("text/plain") && clipboard.types.length === 1; - } - }; - const keyboardCommandFromKeyEvent = function (event) { - const command = []; - if (event.altKey) { - command.push("alt"); - } - if (event.shiftKey) { - command.push("shift"); - } - command.push(event.key); - return command; - }; - const pointFromEvent = event => ({ - x: event.clientX, - y: event.clientY - }); - - const attributeButtonSelector = "[data-trix-attribute]"; - const actionButtonSelector = "[data-trix-action]"; - const toolbarButtonSelector = "".concat(attributeButtonSelector, ", ").concat(actionButtonSelector); - const dialogSelector = "[data-trix-dialog]"; - const activeDialogSelector = "".concat(dialogSelector, "[data-trix-active]"); - const dialogButtonSelector = "".concat(dialogSelector, " [data-trix-method]"); - const dialogInputSelector = "".concat(dialogSelector, " [data-trix-input]"); - const getInputForDialog = (element, attributeName) => { - if (!attributeName) { - attributeName = getAttributeName(element); - } - return element.querySelector("[data-trix-input][name='".concat(attributeName, "']")); - }; - const getActionName = element => element.getAttribute("data-trix-action"); - const getAttributeName = element => { - return element.getAttribute("data-trix-attribute") || element.getAttribute("data-trix-dialog-attribute"); - }; - const getDialogName = element => element.getAttribute("data-trix-dialog"); - class ToolbarController extends BasicObject { - constructor(element) { - super(element); - this.didClickActionButton = this.didClickActionButton.bind(this); - this.didClickAttributeButton = this.didClickAttributeButton.bind(this); - this.didClickDialogButton = this.didClickDialogButton.bind(this); - this.didKeyDownDialogInput = this.didKeyDownDialogInput.bind(this); - this.element = element; - this.attributes = {}; - this.actions = {}; - this.resetDialogInputs(); - handleEvent("mousedown", { - onElement: this.element, - matchingSelector: actionButtonSelector, - withCallback: this.didClickActionButton - }); - handleEvent("mousedown", { - onElement: this.element, - matchingSelector: attributeButtonSelector, - withCallback: this.didClickAttributeButton - }); - handleEvent("click", { - onElement: this.element, - matchingSelector: toolbarButtonSelector, - preventDefault: true - }); - handleEvent("click", { - onElement: this.element, - matchingSelector: dialogButtonSelector, - withCallback: this.didClickDialogButton - }); - handleEvent("keydown", { - onElement: this.element, - matchingSelector: dialogInputSelector, - withCallback: this.didKeyDownDialogInput - }); - } - - // Event handlers - - didClickActionButton(event, element) { - var _this$delegate; - (_this$delegate = this.delegate) === null || _this$delegate === void 0 || _this$delegate.toolbarDidClickButton(); - event.preventDefault(); - const actionName = getActionName(element); - if (this.getDialog(actionName)) { - return this.toggleDialog(actionName); - } else { - var _this$delegate2; - return (_this$delegate2 = this.delegate) === null || _this$delegate2 === void 0 ? void 0 : _this$delegate2.toolbarDidInvokeAction(actionName, element); - } - } - didClickAttributeButton(event, element) { - var _this$delegate3; - (_this$delegate3 = this.delegate) === null || _this$delegate3 === void 0 || _this$delegate3.toolbarDidClickButton(); - event.preventDefault(); - const attributeName = getAttributeName(element); - if (this.getDialog(attributeName)) { - this.toggleDialog(attributeName); - } else { - var _this$delegate4; - (_this$delegate4 = this.delegate) === null || _this$delegate4 === void 0 || _this$delegate4.toolbarDidToggleAttribute(attributeName); - } - return this.refreshAttributeButtons(); - } - didClickDialogButton(event, element) { - const dialogElement = findClosestElementFromNode(element, { - matchingSelector: dialogSelector - }); - const method = element.getAttribute("data-trix-method"); - return this[method].call(this, dialogElement); - } - didKeyDownDialogInput(event, element) { - if (event.keyCode === 13) { - // Enter key - event.preventDefault(); - const attribute = element.getAttribute("name"); - const dialog = this.getDialog(attribute); - this.setAttribute(dialog); - } - if (event.keyCode === 27) { - // Escape key - event.preventDefault(); - return this.hideDialog(); - } - } - - // Action buttons - - updateActions(actions) { - this.actions = actions; - return this.refreshActionButtons(); - } - refreshActionButtons() { - return this.eachActionButton((element, actionName) => { - element.disabled = this.actions[actionName] === false; - }); - } - eachActionButton(callback) { - return Array.from(this.element.querySelectorAll(actionButtonSelector)).map(element => callback(element, getActionName(element))); - } - - // Attribute buttons - - updateAttributes(attributes) { - this.attributes = attributes; - return this.refreshAttributeButtons(); - } - refreshAttributeButtons() { - return this.eachAttributeButton((element, attributeName) => { - element.disabled = this.attributes[attributeName] === false; - if (this.attributes[attributeName] || this.dialogIsVisible(attributeName)) { - element.setAttribute("data-trix-active", ""); - return element.classList.add("trix-active"); - } else { - element.removeAttribute("data-trix-active"); - return element.classList.remove("trix-active"); - } - }); - } - eachAttributeButton(callback) { - return Array.from(this.element.querySelectorAll(attributeButtonSelector)).map(element => callback(element, getAttributeName(element))); - } - applyKeyboardCommand(keys) { - const keyString = JSON.stringify(keys.sort()); - for (const button of Array.from(this.element.querySelectorAll("[data-trix-key]"))) { - const buttonKeys = button.getAttribute("data-trix-key").split("+"); - const buttonKeyString = JSON.stringify(buttonKeys.sort()); - if (buttonKeyString === keyString) { - triggerEvent("mousedown", { - onElement: button - }); - return true; - } - } - return false; - } - - // Dialogs - - dialogIsVisible(dialogName) { - const element = this.getDialog(dialogName); - if (element) { - return element.hasAttribute("data-trix-active"); - } - } - toggleDialog(dialogName) { - if (this.dialogIsVisible(dialogName)) { - return this.hideDialog(); - } else { - return this.showDialog(dialogName); - } - } - showDialog(dialogName) { - var _this$delegate5, _this$delegate6; - this.hideDialog(); - (_this$delegate5 = this.delegate) === null || _this$delegate5 === void 0 || _this$delegate5.toolbarWillShowDialog(); - const element = this.getDialog(dialogName); - element.setAttribute("data-trix-active", ""); - element.classList.add("trix-active"); - Array.from(element.querySelectorAll("input[disabled]")).forEach(disabledInput => { - disabledInput.removeAttribute("disabled"); - }); - const attributeName = getAttributeName(element); - if (attributeName) { - const input = getInputForDialog(element, dialogName); - if (input) { - input.value = this.attributes[attributeName] || ""; - input.select(); - } - } - return (_this$delegate6 = this.delegate) === null || _this$delegate6 === void 0 ? void 0 : _this$delegate6.toolbarDidShowDialog(dialogName); - } - setAttribute(dialogElement) { - var _this$delegate7; - const attributeName = getAttributeName(dialogElement); - const input = getInputForDialog(dialogElement, attributeName); - if (input.willValidate) { - input.setCustomValidity(""); - if (!input.checkValidity() || !this.isSafeAttribute(input)) { - input.setCustomValidity("Invalid value"); - input.setAttribute("data-trix-validate", ""); - input.classList.add("trix-validate"); - return input.focus(); - } - } - (_this$delegate7 = this.delegate) === null || _this$delegate7 === void 0 || _this$delegate7.toolbarDidUpdateAttribute(attributeName, input.value); - return this.hideDialog(); - } - isSafeAttribute(input) { - if (input.hasAttribute("data-trix-validate-href")) { - return purify.isValidAttribute("a", "href", input.value); - } else { - return true; - } - } - removeAttribute(dialogElement) { - var _this$delegate8; - const attributeName = getAttributeName(dialogElement); - (_this$delegate8 = this.delegate) === null || _this$delegate8 === void 0 || _this$delegate8.toolbarDidRemoveAttribute(attributeName); - return this.hideDialog(); - } - hideDialog() { - const element = this.element.querySelector(activeDialogSelector); - if (element) { - var _this$delegate9; - element.removeAttribute("data-trix-active"); - element.classList.remove("trix-active"); - this.resetDialogInputs(); - return (_this$delegate9 = this.delegate) === null || _this$delegate9 === void 0 ? void 0 : _this$delegate9.toolbarDidHideDialog(getDialogName(element)); - } - } - resetDialogInputs() { - Array.from(this.element.querySelectorAll(dialogInputSelector)).forEach(input => { - input.setAttribute("disabled", "disabled"); - input.removeAttribute("data-trix-validate"); - input.classList.remove("trix-validate"); - }); - } - getDialog(dialogName) { - return this.element.querySelector("[data-trix-dialog=".concat(dialogName, "]")); - } - } - - const snapshotsAreEqual = (a, b) => rangesAreEqual(a.selectedRange, b.selectedRange) && a.document.isEqualTo(b.document); - class EditorController extends Controller { - constructor(_ref) { - let { - editorElement, - document, - html - } = _ref; - super(...arguments); - this.editorElement = editorElement; - this.selectionManager = new SelectionManager(this.editorElement); - this.selectionManager.delegate = this; - this.composition = new Composition(); - this.composition.delegate = this; - this.attachmentManager = new AttachmentManager(this.composition.getAttachments()); - this.attachmentManager.delegate = this; - this.inputController = input.getLevel() === 2 ? new Level2InputController(this.editorElement) : new Level0InputController(this.editorElement); - this.inputController.delegate = this; - this.inputController.responder = this.composition; - this.compositionController = new CompositionController(this.editorElement, this.composition); - this.compositionController.delegate = this; - this.toolbarController = new ToolbarController(this.editorElement.toolbarElement); - this.toolbarController.delegate = this; - this.editor = new Editor(this.composition, this.selectionManager, this.editorElement); - if (document) { - this.editor.loadDocument(document); - } else { - this.editor.loadHTML(html); - } - } - registerSelectionManager() { - return selectionChangeObserver.registerSelectionManager(this.selectionManager); - } - unregisterSelectionManager() { - return selectionChangeObserver.unregisterSelectionManager(this.selectionManager); - } - render() { - return this.compositionController.render(); - } - reparse() { - return this.composition.replaceHTML(this.editorElement.innerHTML); - } - - // Composition delegate - - compositionDidChangeDocument(document) { - this.notifyEditorElement("document-change"); - if (!this.handlingInput) { - return this.render(); - } - } - compositionDidChangeCurrentAttributes(currentAttributes) { - this.currentAttributes = currentAttributes; - this.toolbarController.updateAttributes(this.currentAttributes); - this.updateCurrentActions(); - return this.notifyEditorElement("attributes-change", { - attributes: this.currentAttributes - }); - } - compositionDidPerformInsertionAtRange(range) { - if (this.pasting) { - this.pastedRange = range; - } - } - compositionShouldAcceptFile(file) { - return this.notifyEditorElement("file-accept", { - file - }); - } - compositionDidAddAttachment(attachment) { - const managedAttachment = this.attachmentManager.manageAttachment(attachment); - return this.notifyEditorElement("attachment-add", { - attachment: managedAttachment - }); - } - compositionDidEditAttachment(attachment) { - this.compositionController.rerenderViewForObject(attachment); - const managedAttachment = this.attachmentManager.manageAttachment(attachment); - this.notifyEditorElement("attachment-edit", { - attachment: managedAttachment - }); - return this.notifyEditorElement("change"); - } - compositionDidChangeAttachmentPreviewURL(attachment) { - this.compositionController.invalidateViewForObject(attachment); - return this.notifyEditorElement("change"); - } - compositionDidRemoveAttachment(attachment) { - const managedAttachment = this.attachmentManager.unmanageAttachment(attachment); - return this.notifyEditorElement("attachment-remove", { - attachment: managedAttachment - }); - } - compositionDidStartEditingAttachment(attachment, options) { - this.attachmentLocationRange = this.composition.document.getLocationRangeOfAttachment(attachment); - this.compositionController.installAttachmentEditorForAttachment(attachment, options); - return this.selectionManager.setLocationRange(this.attachmentLocationRange); - } - compositionDidStopEditingAttachment(attachment) { - this.compositionController.uninstallAttachmentEditor(); - this.attachmentLocationRange = null; - } - compositionDidRequestChangingSelectionToLocationRange(locationRange) { - if (this.loadingSnapshot && !this.isFocused()) return; - this.requestedLocationRange = locationRange; - this.compositionRevisionWhenLocationRangeRequested = this.composition.revision; - if (!this.handlingInput) { - return this.render(); - } - } - compositionWillLoadSnapshot() { - this.loadingSnapshot = true; - } - compositionDidLoadSnapshot() { - this.compositionController.refreshViewCache(); - this.render(); - this.loadingSnapshot = false; - } - getSelectionManager() { - return this.selectionManager; - } - - // Attachment manager delegate - - attachmentManagerDidRequestRemovalOfAttachment(attachment) { - return this.removeAttachment(attachment); - } - - // Document controller delegate - - compositionControllerWillSyncDocumentView() { - this.inputController.editorWillSyncDocumentView(); - this.selectionManager.lock(); - return this.selectionManager.clearSelection(); - } - compositionControllerDidSyncDocumentView() { - this.inputController.editorDidSyncDocumentView(); - this.selectionManager.unlock(); - this.updateCurrentActions(); - return this.notifyEditorElement("sync"); - } - compositionControllerDidRender() { - if (this.requestedLocationRange) { - if (this.compositionRevisionWhenLocationRangeRequested === this.composition.revision) { - this.selectionManager.setLocationRange(this.requestedLocationRange); - } - this.requestedLocationRange = null; - this.compositionRevisionWhenLocationRangeRequested = null; - } - if (this.renderedCompositionRevision !== this.composition.revision) { - this.runEditorFilters(); - this.composition.updateCurrentAttributes(); - this.notifyEditorElement("render"); - } - this.renderedCompositionRevision = this.composition.revision; - } - compositionControllerDidFocus() { - if (this.isFocusedInvisibly()) { - this.setLocationRange({ - index: 0, - offset: 0 - }); - } - this.toolbarController.hideDialog(); - return this.notifyEditorElement("focus"); - } - compositionControllerDidBlur() { - return this.notifyEditorElement("blur"); - } - compositionControllerDidSelectAttachment(attachment, options) { - this.toolbarController.hideDialog(); - return this.composition.editAttachment(attachment, options); - } - compositionControllerDidRequestDeselectingAttachment(attachment) { - const locationRange = this.attachmentLocationRange || this.composition.document.getLocationRangeOfAttachment(attachment); - return this.selectionManager.setLocationRange(locationRange[1]); - } - compositionControllerWillUpdateAttachment(attachment) { - return this.editor.recordUndoEntry("Edit Attachment", { - context: attachment.id, - consolidatable: true - }); - } - compositionControllerDidRequestRemovalOfAttachment(attachment) { - return this.removeAttachment(attachment); - } - - // Input controller delegate - - inputControllerWillHandleInput() { - this.handlingInput = true; - this.requestedRender = false; - } - inputControllerDidRequestRender() { - this.requestedRender = true; - } - inputControllerDidHandleInput() { - this.handlingInput = false; - if (this.requestedRender) { - this.requestedRender = false; - return this.render(); - } - } - inputControllerDidAllowUnhandledInput() { - return this.notifyEditorElement("change"); - } - inputControllerDidRequestReparse() { - return this.reparse(); - } - inputControllerWillPerformTyping() { - return this.recordTypingUndoEntry(); - } - inputControllerWillPerformFormatting(attributeName) { - return this.recordFormattingUndoEntry(attributeName); - } - inputControllerWillCutText() { - return this.editor.recordUndoEntry("Cut"); - } - inputControllerWillPaste(paste) { - this.editor.recordUndoEntry("Paste"); - this.pasting = true; - return this.notifyEditorElement("before-paste", { - paste - }); - } - inputControllerDidPaste(paste) { - paste.range = this.pastedRange; - this.pastedRange = null; - this.pasting = null; - return this.notifyEditorElement("paste", { - paste - }); - } - inputControllerWillMoveText() { - return this.editor.recordUndoEntry("Move"); - } - inputControllerWillAttachFiles() { - return this.editor.recordUndoEntry("Drop Files"); - } - inputControllerWillPerformUndo() { - return this.editor.undo(); - } - inputControllerWillPerformRedo() { - return this.editor.redo(); - } - inputControllerDidReceiveKeyboardCommand(keys) { - return this.toolbarController.applyKeyboardCommand(keys); - } - inputControllerDidStartDrag() { - this.locationRangeBeforeDrag = this.selectionManager.getLocationRange(); - } - inputControllerDidReceiveDragOverPoint(point) { - return this.selectionManager.setLocationRangeFromPointRange(point); - } - inputControllerDidCancelDrag() { - this.selectionManager.setLocationRange(this.locationRangeBeforeDrag); - this.locationRangeBeforeDrag = null; - } - - // Selection manager delegate - - locationRangeDidChange(locationRange) { - this.composition.updateCurrentAttributes(); - this.updateCurrentActions(); - if (this.attachmentLocationRange && !rangesAreEqual(this.attachmentLocationRange, locationRange)) { - this.composition.stopEditingAttachment(); - } - return this.notifyEditorElement("selection-change"); - } - - // Toolbar controller delegate - - toolbarDidClickButton() { - if (!this.getLocationRange()) { - return this.setLocationRange({ - index: 0, - offset: 0 - }); - } - } - toolbarDidInvokeAction(actionName, invokingElement) { - return this.invokeAction(actionName, invokingElement); - } - toolbarDidToggleAttribute(attributeName) { - this.recordFormattingUndoEntry(attributeName); - this.composition.toggleCurrentAttribute(attributeName); - this.render(); - if (!this.selectionFrozen) { - return this.editorElement.focus(); - } - } - toolbarDidUpdateAttribute(attributeName, value) { - this.recordFormattingUndoEntry(attributeName); - this.composition.setCurrentAttribute(attributeName, value); - this.render(); - if (!this.selectionFrozen) { - return this.editorElement.focus(); - } - } - toolbarDidRemoveAttribute(attributeName) { - this.recordFormattingUndoEntry(attributeName); - this.composition.removeCurrentAttribute(attributeName); - this.render(); - if (!this.selectionFrozen) { - return this.editorElement.focus(); - } - } - toolbarWillShowDialog(dialogElement) { - this.composition.expandSelectionForEditing(); - return this.freezeSelection(); - } - toolbarDidShowDialog(dialogName) { - return this.notifyEditorElement("toolbar-dialog-show", { - dialogName - }); - } - toolbarDidHideDialog(dialogName) { - this.thawSelection(); - this.editorElement.focus(); - return this.notifyEditorElement("toolbar-dialog-hide", { - dialogName - }); - } - - // Selection - - freezeSelection() { - if (!this.selectionFrozen) { - this.selectionManager.lock(); - this.composition.freezeSelection(); - this.selectionFrozen = true; - return this.render(); - } - } - thawSelection() { - if (this.selectionFrozen) { - this.composition.thawSelection(); - this.selectionManager.unlock(); - this.selectionFrozen = false; - return this.render(); - } - } - canInvokeAction(actionName) { - if (this.actionIsExternal(actionName)) { - return true; - } else { - var _this$actions$actionN; - return !!((_this$actions$actionN = this.actions[actionName]) !== null && _this$actions$actionN !== void 0 && (_this$actions$actionN = _this$actions$actionN.test) !== null && _this$actions$actionN !== void 0 && _this$actions$actionN.call(this)); - } - } - invokeAction(actionName, invokingElement) { - if (this.actionIsExternal(actionName)) { - return this.notifyEditorElement("action-invoke", { - actionName, - invokingElement - }); - } else { - var _this$actions$actionN2; - return (_this$actions$actionN2 = this.actions[actionName]) === null || _this$actions$actionN2 === void 0 || (_this$actions$actionN2 = _this$actions$actionN2.perform) === null || _this$actions$actionN2 === void 0 ? void 0 : _this$actions$actionN2.call(this); - } - } - actionIsExternal(actionName) { - return /^x-./.test(actionName); - } - getCurrentActions() { - const result = {}; - for (const actionName in this.actions) { - result[actionName] = this.canInvokeAction(actionName); - } - return result; - } - updateCurrentActions() { - const currentActions = this.getCurrentActions(); - if (!objectsAreEqual(currentActions, this.currentActions)) { - this.currentActions = currentActions; - this.toolbarController.updateActions(this.currentActions); - return this.notifyEditorElement("actions-change", { - actions: this.currentActions - }); - } - } - - // Editor filters - - runEditorFilters() { - let snapshot = this.composition.getSnapshot(); - Array.from(this.editor.filters).forEach(filter => { - const { - document, - selectedRange - } = snapshot; - snapshot = filter.call(this.editor, snapshot) || {}; - if (!snapshot.document) { - snapshot.document = document; - } - if (!snapshot.selectedRange) { - snapshot.selectedRange = selectedRange; - } - }); - if (!snapshotsAreEqual(snapshot, this.composition.getSnapshot())) { - return this.composition.loadSnapshot(snapshot); - } - } - - // Private - - updateInputElement() { - const element = this.compositionController.getSerializableElement(); - const value = serializeToContentType(element, "text/html"); - return this.editorElement.setFormValue(value); - } - notifyEditorElement(message, data) { - switch (message) { - case "document-change": - this.documentChangedSinceLastRender = true; - break; - case "render": - if (this.documentChangedSinceLastRender) { - this.documentChangedSinceLastRender = false; - this.notifyEditorElement("change"); - } - break; - case "change": - case "attachment-add": - case "attachment-edit": - case "attachment-remove": - this.updateInputElement(); - break; - } - return this.editorElement.notify(message, data); - } - removeAttachment(attachment) { - this.editor.recordUndoEntry("Delete Attachment"); - this.composition.removeAttachment(attachment); - return this.render(); - } - recordFormattingUndoEntry(attributeName) { - const blockConfig = getBlockConfig(attributeName); - const locationRange = this.selectionManager.getLocationRange(); - if (blockConfig || !rangeIsCollapsed(locationRange)) { - return this.editor.recordUndoEntry("Formatting", { - context: this.getUndoContext(), - consolidatable: true - }); - } - } - recordTypingUndoEntry() { - return this.editor.recordUndoEntry("Typing", { - context: this.getUndoContext(this.currentAttributes), - consolidatable: true - }); - } - getUndoContext() { - for (var _len = arguments.length, context = new Array(_len), _key = 0; _key < _len; _key++) { - context[_key] = arguments[_key]; - } - return [this.getLocationContext(), this.getTimeContext(), ...Array.from(context)]; - } - getLocationContext() { - const locationRange = this.selectionManager.getLocationRange(); - if (rangeIsCollapsed(locationRange)) { - return locationRange[0].index; - } else { - return locationRange; - } - } - getTimeContext() { - if (undo.interval > 0) { - return Math.floor(new Date().getTime() / undo.interval); - } else { - return 0; - } - } - isFocused() { - var _this$editorElement$o; - return this.editorElement === ((_this$editorElement$o = this.editorElement.ownerDocument) === null || _this$editorElement$o === void 0 ? void 0 : _this$editorElement$o.activeElement); - } - - // Detect "Cursor disappears sporadically" Firefox bug. - // - https://bugzilla.mozilla.org/show_bug.cgi?id=226301 - isFocusedInvisibly() { - return this.isFocused() && !this.getLocationRange(); - } - get actions() { - return this.constructor.actions; - } - } - _defineProperty(EditorController, "actions", { - undo: { - test() { - return this.editor.canUndo(); - }, - perform() { - return this.editor.undo(); - } - }, - redo: { - test() { - return this.editor.canRedo(); - }, - perform() { - return this.editor.redo(); - } - }, - link: { - test() { - return this.editor.canActivateAttribute("href"); - } - }, - increaseNestingLevel: { - test() { - return this.editor.canIncreaseNestingLevel(); - }, - perform() { - return this.editor.increaseNestingLevel() && this.render(); - } - }, - decreaseNestingLevel: { - test() { - return this.editor.canDecreaseNestingLevel(); - }, - perform() { - return this.editor.decreaseNestingLevel() && this.render(); - } - }, - attachFiles: { - test() { - return true; - }, - perform() { - return input.pickFiles(this.editor.insertFiles); - } - } - }); - EditorController.proxyMethod("getSelectionManager().setLocationRange"); - EditorController.proxyMethod("getSelectionManager().getLocationRange"); - - var controllers = /*#__PURE__*/Object.freeze({ - __proto__: null, - AttachmentEditorController: AttachmentEditorController, - CompositionController: CompositionController, - Controller: Controller, - EditorController: EditorController, - InputController: InputController, - Level0InputController: Level0InputController, - Level2InputController: Level2InputController, - ToolbarController: ToolbarController - }); - - var observers = /*#__PURE__*/Object.freeze({ - __proto__: null, - MutationObserver: MutationObserver, - SelectionChangeObserver: SelectionChangeObserver - }); - - var operations = /*#__PURE__*/Object.freeze({ - __proto__: null, - FileVerificationOperation: FileVerificationOperation, - ImagePreloadOperation: ImagePreloadOperation - }); - - installDefaultCSSForTagName("trix-toolbar", "%t {\n display: block;\n}\n\n%t {\n white-space: nowrap;\n}\n\n%t [data-trix-dialog] {\n display: none;\n}\n\n%t [data-trix-dialog][data-trix-active] {\n display: block;\n}\n\n%t [data-trix-dialog] [data-trix-validate]:invalid {\n background-color: #ffdddd;\n}"); - class TrixToolbarElement extends HTMLElement { - // Element lifecycle - - connectedCallback() { - if (this.innerHTML === "") { - this.innerHTML = toolbar.getDefaultHTML(); - } - } - } - - let id = 0; - - // Contenteditable support helpers - - const autofocus = function (element) { - if (!document.querySelector(":focus")) { - if (element.hasAttribute("autofocus") && document.querySelector("[autofocus]") === element) { - return element.focus(); - } - } - }; - const makeEditable = function (element) { - if (element.hasAttribute("contenteditable")) { - return; - } - element.setAttribute("contenteditable", ""); - return handleEventOnce("focus", { - onElement: element, - withCallback() { - return configureContentEditable(element); - } - }); - }; - const configureContentEditable = function (element) { - disableObjectResizing(element); - return setDefaultParagraphSeparator(element); - }; - const disableObjectResizing = function (element) { - var _document$queryComman, _document; - if ((_document$queryComman = (_document = document).queryCommandSupported) !== null && _document$queryComman !== void 0 && _document$queryComman.call(_document, "enableObjectResizing")) { - document.execCommand("enableObjectResizing", false, false); - return handleEvent("mscontrolselect", { - onElement: element, - preventDefault: true - }); - } - }; - const setDefaultParagraphSeparator = function (element) { - var _document$queryComman2, _document2; - if ((_document$queryComman2 = (_document2 = document).queryCommandSupported) !== null && _document$queryComman2 !== void 0 && _document$queryComman2.call(_document2, "DefaultParagraphSeparator")) { - const { - tagName - } = attributes.default; - if (["div", "p"].includes(tagName)) { - return document.execCommand("DefaultParagraphSeparator", false, tagName); - } - } - }; - - // Accessibility helpers - - const addAccessibilityRole = function (element) { - if (element.hasAttribute("role")) { - return; - } - return element.setAttribute("role", "textbox"); - }; - const ensureAriaLabel = function (element) { - if (element.hasAttribute("aria-label") || element.hasAttribute("aria-labelledby")) { - return; - } - const update = function () { - const texts = Array.from(element.labels).map(label => { - if (!label.contains(element)) return label.textContent; - }).filter(text => text); - const text = texts.join(" "); - if (text) { - return element.setAttribute("aria-label", text); - } else { - return element.removeAttribute("aria-label"); - } - }; - update(); - return handleEvent("focus", { - onElement: element, - withCallback: update - }); - }; - - // Style - - const cursorTargetStyles = function () { - if (browser$1.forcesObjectResizing) { - return { - display: "inline", - width: "auto" - }; - } else { - return { - display: "inline-block", - width: "1px" - }; - } - }(); - installDefaultCSSForTagName("trix-editor", "%t {\n display: block;\n}\n\n%t:empty::before {\n content: attr(placeholder);\n color: graytext;\n cursor: text;\n pointer-events: none;\n white-space: pre-line;\n}\n\n%t a[contenteditable=false] {\n cursor: text;\n}\n\n%t img {\n max-width: 100%;\n height: auto;\n}\n\n%t ".concat(attachmentSelector, " figcaption textarea {\n resize: none;\n}\n\n%t ").concat(attachmentSelector, " figcaption textarea.trix-autoresize-clone {\n position: absolute;\n left: -9999px;\n max-height: 0px;\n}\n\n%t ").concat(attachmentSelector, " figcaption[data-trix-placeholder]:empty::before {\n content: attr(data-trix-placeholder);\n color: graytext;\n}\n\n%t [data-trix-cursor-target] {\n display: ").concat(cursorTargetStyles.display, " !important;\n width: ").concat(cursorTargetStyles.width, " !important;\n padding: 0 !important;\n margin: 0 !important;\n border: none !important;\n}\n\n%t [data-trix-cursor-target=left] {\n vertical-align: top !important;\n margin-left: -1px !important;\n}\n\n%t [data-trix-cursor-target=right] {\n vertical-align: bottom !important;\n margin-right: -1px !important;\n}")); - var _internals = /*#__PURE__*/new WeakMap(); - var _validate = /*#__PURE__*/new WeakSet(); - class ElementInternalsDelegate { - constructor(element) { - _classPrivateMethodInitSpec(this, _validate); - _classPrivateFieldInitSpec(this, _internals, { - writable: true, - value: void 0 - }); - this.element = element; - _classPrivateFieldSet(this, _internals, element.attachInternals()); - } - connectedCallback() { - _classPrivateMethodGet(this, _validate, _validate2).call(this); - } - disconnectedCallback() {} - get labels() { - return _classPrivateFieldGet(this, _internals).labels; - } - get disabled() { - var _this$element$inputEl; - return (_this$element$inputEl = this.element.inputElement) === null || _this$element$inputEl === void 0 ? void 0 : _this$element$inputEl.disabled; - } - set disabled(value) { - this.element.toggleAttribute("disabled", value); - } - get required() { - return this.element.hasAttribute("required"); - } - set required(value) { - this.element.toggleAttribute("required", value); - _classPrivateMethodGet(this, _validate, _validate2).call(this); - } - get validity() { - return _classPrivateFieldGet(this, _internals).validity; - } - get validationMessage() { - return _classPrivateFieldGet(this, _internals).validationMessage; - } - get willValidate() { - return _classPrivateFieldGet(this, _internals).willValidate; - } - setFormValue(value) { - _classPrivateMethodGet(this, _validate, _validate2).call(this); - } - checkValidity() { - return _classPrivateFieldGet(this, _internals).checkValidity(); - } - reportValidity() { - return _classPrivateFieldGet(this, _internals).reportValidity(); - } - setCustomValidity(validationMessage) { - _classPrivateMethodGet(this, _validate, _validate2).call(this, validationMessage); - } - } - function _validate2() { - let customValidationMessage = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : ""; - const { - required, - value - } = this.element; - const valueMissing = required && !value; - const customError = !!customValidationMessage; - const input = makeElement("input", { - required - }); - const validationMessage = customValidationMessage || input.validationMessage; - _classPrivateFieldGet(this, _internals).setValidity({ - valueMissing, - customError - }, validationMessage); - } - var _focusHandler = /*#__PURE__*/new WeakMap(); - var _resetBubbled = /*#__PURE__*/new WeakMap(); - var _clickBubbled = /*#__PURE__*/new WeakMap(); - class LegacyDelegate { - constructor(element) { - _classPrivateFieldInitSpec(this, _focusHandler, { - writable: true, - value: void 0 - }); - _classPrivateFieldInitSpec(this, _resetBubbled, { - writable: true, - value: event => { - if (event.defaultPrevented) return; - if (event.target !== this.element.form) return; - this.element.reset(); - } - }); - _classPrivateFieldInitSpec(this, _clickBubbled, { - writable: true, - value: event => { - if (event.defaultPrevented) return; - if (this.element.contains(event.target)) return; - const label = findClosestElementFromNode(event.target, { - matchingSelector: "label" - }); - if (!label) return; - if (!Array.from(this.labels).includes(label)) return; - this.element.focus(); - } - }); - this.element = element; - } - connectedCallback() { - _classPrivateFieldSet(this, _focusHandler, ensureAriaLabel(this.element)); - window.addEventListener("reset", _classPrivateFieldGet(this, _resetBubbled), false); - window.addEventListener("click", _classPrivateFieldGet(this, _clickBubbled), false); - } - disconnectedCallback() { - var _classPrivateFieldGet2; - (_classPrivateFieldGet2 = _classPrivateFieldGet(this, _focusHandler)) === null || _classPrivateFieldGet2 === void 0 || _classPrivateFieldGet2.destroy(); - window.removeEventListener("reset", _classPrivateFieldGet(this, _resetBubbled), false); - window.removeEventListener("click", _classPrivateFieldGet(this, _clickBubbled), false); - } - get labels() { - const labels = []; - if (this.element.id && this.element.ownerDocument) { - labels.push(...Array.from(this.element.ownerDocument.querySelectorAll("label[for='".concat(this.element.id, "']")) || [])); - } - const label = findClosestElementFromNode(this.element, { - matchingSelector: "label" - }); - if (label) { - if ([this.element, null].includes(label.control)) { - labels.push(label); - } - } - return labels; - } - get disabled() { - console.warn("This browser does not support the [disabled] attribute for trix-editor elements."); - return false; - } - set disabled(value) { - console.warn("This browser does not support the [disabled] attribute for trix-editor elements."); - } - get required() { - console.warn("This browser does not support the [required] attribute for trix-editor elements."); - return false; - } - set required(value) { - console.warn("This browser does not support the [required] attribute for trix-editor elements."); - } - get validity() { - console.warn("This browser does not support the validity property for trix-editor elements."); - return null; - } - get validationMessage() { - console.warn("This browser does not support the validationMessage property for trix-editor elements."); - return ""; - } - get willValidate() { - console.warn("This browser does not support the willValidate property for trix-editor elements."); - return false; - } - setFormValue(value) {} - checkValidity() { - console.warn("This browser does not support checkValidity() for trix-editor elements."); - return true; - } - reportValidity() { - console.warn("This browser does not support reportValidity() for trix-editor elements."); - return true; - } - setCustomValidity(validationMessage) { - console.warn("This browser does not support setCustomValidity(validationMessage) for trix-editor elements."); - } - } - var _delegate = /*#__PURE__*/new WeakMap(); - class TrixEditorElement extends HTMLElement { - constructor() { - super(); - _classPrivateFieldInitSpec(this, _delegate, { - writable: true, - value: void 0 - }); - _classPrivateFieldSet(this, _delegate, this.constructor.formAssociated ? new ElementInternalsDelegate(this) : new LegacyDelegate(this)); - } - - // Properties - - get trixId() { - if (this.hasAttribute("trix-id")) { - return this.getAttribute("trix-id"); - } else { - this.setAttribute("trix-id", ++id); - return this.trixId; - } - } - get labels() { - return _classPrivateFieldGet(this, _delegate).labels; - } - get disabled() { - return _classPrivateFieldGet(this, _delegate).disabled; - } - set disabled(value) { - _classPrivateFieldGet(this, _delegate).disabled = value; - } - get required() { - return _classPrivateFieldGet(this, _delegate).required; - } - set required(value) { - _classPrivateFieldGet(this, _delegate).required = value; - } - get validity() { - return _classPrivateFieldGet(this, _delegate).validity; - } - get validationMessage() { - return _classPrivateFieldGet(this, _delegate).validationMessage; - } - get willValidate() { - return _classPrivateFieldGet(this, _delegate).willValidate; - } - get type() { - return this.localName; - } - get toolbarElement() { - if (this.hasAttribute("toolbar")) { - var _this$ownerDocument; - return (_this$ownerDocument = this.ownerDocument) === null || _this$ownerDocument === void 0 ? void 0 : _this$ownerDocument.getElementById(this.getAttribute("toolbar")); - } else if (this.parentNode) { - const toolbarId = "trix-toolbar-".concat(this.trixId); - this.setAttribute("toolbar", toolbarId); - this.internalToolbar = makeElement("trix-toolbar", { - id: toolbarId - }); - this.parentNode.insertBefore(this.internalToolbar, this); - return this.internalToolbar; - } else { - return undefined; - } - } - get form() { - var _this$inputElement; - return (_this$inputElement = this.inputElement) === null || _this$inputElement === void 0 ? void 0 : _this$inputElement.form; - } - get inputElement() { - if (this.hasAttribute("input")) { - var _this$ownerDocument2; - return (_this$ownerDocument2 = this.ownerDocument) === null || _this$ownerDocument2 === void 0 ? void 0 : _this$ownerDocument2.getElementById(this.getAttribute("input")); - } else if (this.parentNode) { - const inputId = "trix-input-".concat(this.trixId); - this.setAttribute("input", inputId); - const element = makeElement("input", { - type: "hidden", - id: inputId - }); - this.parentNode.insertBefore(element, this.nextElementSibling); - return element; - } else { - return undefined; - } - } - get editor() { - var _this$editorControlle; - return (_this$editorControlle = this.editorController) === null || _this$editorControlle === void 0 ? void 0 : _this$editorControlle.editor; - } - get name() { - var _this$inputElement2; - return (_this$inputElement2 = this.inputElement) === null || _this$inputElement2 === void 0 ? void 0 : _this$inputElement2.name; - } - get value() { - var _this$inputElement3; - return (_this$inputElement3 = this.inputElement) === null || _this$inputElement3 === void 0 ? void 0 : _this$inputElement3.value; - } - set value(defaultValue) { - var _this$editor; - this.defaultValue = defaultValue; - (_this$editor = this.editor) === null || _this$editor === void 0 || _this$editor.loadHTML(this.defaultValue); - } - - // Element callbacks - - attributeChangedCallback(name, oldValue, newValue) { - if (name === "connected" && this.isConnected && oldValue != null && oldValue !== newValue) { - requestAnimationFrame(() => this.reconnect()); - } - } - - // Controller delegate methods - - notify(message, data) { - if (this.editorController) { - return triggerEvent("trix-".concat(message), { - onElement: this, - attributes: data - }); - } - } - setFormValue(value) { - if (this.inputElement) { - this.inputElement.value = value; - _classPrivateFieldGet(this, _delegate).setFormValue(value); - } - } - - // Element lifecycle - - connectedCallback() { - if (!this.hasAttribute("data-trix-internal")) { - makeEditable(this); - addAccessibilityRole(this); - if (!this.editorController) { - triggerEvent("trix-before-initialize", { - onElement: this - }); - this.editorController = new EditorController({ - editorElement: this, - html: this.defaultValue = this.value - }); - requestAnimationFrame(() => triggerEvent("trix-initialize", { - onElement: this - })); - } - this.editorController.registerSelectionManager(); - _classPrivateFieldGet(this, _delegate).connectedCallback(); - this.toggleAttribute("connected", true); - autofocus(this); - } - } - disconnectedCallback() { - var _this$editorControlle2; - (_this$editorControlle2 = this.editorController) === null || _this$editorControlle2 === void 0 || _this$editorControlle2.unregisterSelectionManager(); - _classPrivateFieldGet(this, _delegate).disconnectedCallback(); - this.toggleAttribute("connected", false); - } - reconnect() { - this.removeInternalToolbar(); - this.disconnectedCallback(); - this.connectedCallback(); - } - removeInternalToolbar() { - var _this$internalToolbar; - (_this$internalToolbar = this.internalToolbar) === null || _this$internalToolbar === void 0 || _this$internalToolbar.remove(); - this.internalToolbar = null; - } - - // Form support - - checkValidity() { - return _classPrivateFieldGet(this, _delegate).checkValidity(); - } - reportValidity() { - return _classPrivateFieldGet(this, _delegate).reportValidity(); - } - setCustomValidity(validationMessage) { - _classPrivateFieldGet(this, _delegate).setCustomValidity(validationMessage); - } - formDisabledCallback(disabled) { - if (this.inputElement) { - this.inputElement.disabled = disabled; - } - this.toggleAttribute("contenteditable", !disabled); - } - formResetCallback() { - this.reset(); - } - reset() { - this.value = this.defaultValue; - } - } - _defineProperty(TrixEditorElement, "formAssociated", "ElementInternals" in window); - _defineProperty(TrixEditorElement, "observedAttributes", ["connected"]); - - var elements = /*#__PURE__*/Object.freeze({ - __proto__: null, - TrixEditorElement: TrixEditorElement, - TrixToolbarElement: TrixToolbarElement - }); - - var filters = /*#__PURE__*/Object.freeze({ - __proto__: null, - Filter: Filter, - attachmentGalleryFilter: attachmentGalleryFilter - }); - - const Trix = { - VERSION: version, - config, - core, - models, - views, - controllers, - observers, - operations, - elements, - filters - }; - - // Expose models under the Trix constant for compatibility with v1 - Object.assign(Trix, models); - function start() { - if (!customElements.get("trix-toolbar")) { - customElements.define("trix-toolbar", TrixToolbarElement); - } - if (!customElements.get("trix-editor")) { - customElements.define("trix-editor", TrixEditorElement); - } - } - window.Trix = Trix; - setTimeout(start, 0); - - return Trix; - -})); diff --git a/actiontext/app/assets/stylesheets/trix.css b/actiontext/app/assets/stylesheets/trix.css deleted file mode 100644 index 84da0eafcfefc..0000000000000 --- a/actiontext/app/assets/stylesheets/trix.css +++ /dev/null @@ -1,470 +0,0 @@ -@charset "UTF-8"; -trix-editor { - border: 1px solid #bbb; - border-radius: 3px; - margin: 0; - padding: 0.4em 0.6em; - min-height: 5em; - outline: none; -} - -trix-toolbar * { - box-sizing: border-box; -} -trix-toolbar .trix-button-row { - display: flex; - flex-wrap: nowrap; - justify-content: space-between; - overflow-x: auto; -} -trix-toolbar .trix-button-group { - display: flex; - margin-bottom: 10px; - border: 1px solid #bbb; - border-top-color: #ccc; - border-bottom-color: #888; - border-radius: 3px; -} -trix-toolbar .trix-button-group:not(:first-child) { - margin-left: 1.5vw; -} -@media (max-width: 768px) { - trix-toolbar .trix-button-group:not(:first-child) { - margin-left: 0; - } -} -trix-toolbar .trix-button-group-spacer { - flex-grow: 1; -} -@media (max-width: 768px) { - trix-toolbar .trix-button-group-spacer { - display: none; - } -} -trix-toolbar .trix-button { - position: relative; - float: left; - color: rgba(0, 0, 0, 0.6); - font-size: 0.75em; - font-weight: 600; - white-space: nowrap; - padding: 0 0.5em; - margin: 0; - outline: none; - border: none; - border-bottom: 1px solid #ddd; - border-radius: 0; - background: transparent; -} -trix-toolbar .trix-button:not(:first-child) { - border-left: 1px solid #ccc; -} -trix-toolbar .trix-button.trix-active { - background: #cbeefa; - color: rgb(0, 0, 0); -} -trix-toolbar .trix-button:not(:disabled) { - cursor: pointer; -} -trix-toolbar .trix-button:disabled { - color: rgba(0, 0, 0, 0.125); -} -@media (max-width: 768px) { - trix-toolbar .trix-button { - letter-spacing: -0.01em; - padding: 0 0.3em; - } -} -trix-toolbar .trix-button--icon { - font-size: inherit; - width: 2.6em; - height: 1.6em; - max-width: calc(0.8em + 4vw); - text-indent: -9999px; -} -@media (max-width: 768px) { - trix-toolbar .trix-button--icon { - height: 2em; - max-width: calc(0.8em + 3.5vw); - } -} -trix-toolbar .trix-button--icon::before { - display: inline-block; - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; - opacity: 0.6; - content: ""; - background-position: center; - background-repeat: no-repeat; - background-size: contain; -} -@media (max-width: 768px) { - trix-toolbar .trix-button--icon::before { - right: 6%; - left: 6%; - } -} -trix-toolbar .trix-button--icon.trix-active::before { - opacity: 1; -} -trix-toolbar .trix-button--icon:disabled::before { - opacity: 0.125; -} -trix-toolbar .trix-button--icon-attach::before { - background-image: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20d%3D%22M10.5%2018V7.5c0-2.25%203-2.25%203%200V18c0%204.125-6%204.125-6%200V7.5c0-6.375%209-6.375%209%200V18%22%20stroke%3D%22%23000%22%20stroke-width%3D%222%22%20stroke-miterlimit%3D%2210%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%2F%3E%3C%2Fsvg%3E"); - top: 8%; - bottom: 4%; -} -trix-toolbar .trix-button--icon-bold::before { - background-image: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M6.522%2019.242a.5.5%200%200%201-.5-.5V5.35a.5.5%200%200%201%20.5-.5h5.783c1.347%200%202.46.345%203.24.982.783.64%201.216%201.562%201.216%202.683%200%201.13-.587%202.129-1.476%202.71a.35.35%200%200%200%20.049.613c1.259.56%202.101%201.742%202.101%203.22%200%201.282-.483%202.334-1.363%203.063-.876.726-2.132%201.12-3.66%201.12h-5.89ZM9.27%207.347v3.362h1.97c.766%200%201.347-.17%201.733-.464.38-.291.587-.716.587-1.27%200-.53-.183-.928-.513-1.198-.334-.273-.838-.43-1.505-.43H9.27Zm0%205.606v3.791h2.389c.832%200%201.448-.177%201.853-.497.399-.315.614-.786.614-1.423%200-.62-.22-1.077-.63-1.385-.418-.313-1.053-.486-1.905-.486H9.27Z%22%20fill%3D%22%23000%22%2F%3E%3C%2Fsvg%3E"); -} -trix-toolbar .trix-button--icon-italic::before { - background-image: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M9%205h6.5v2h-2.23l-2.31%2010H13v2H6v-2h2.461l2.306-10H9V5Z%22%20fill%3D%22%23000%22%2F%3E%3C%2Fsvg%3E"); -} -trix-toolbar .trix-button--icon-link::before { - background-image: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M18.948%205.258a4.337%204.337%200%200%200-6.108%200L11.217%206.87a.993.993%200%200%200%200%201.41c.392.39%201.027.39%201.418%200l1.623-1.613a2.323%202.323%200%200%201%203.271%200%202.29%202.29%200%200%201%200%203.251l-2.393%202.38a3.021%203.021%200%200%201-4.255%200l-.05-.049a1.007%201.007%200%200%200-1.418%200%20.993.993%200%200%200%200%201.41l.05.049a5.036%205.036%200%200%200%207.091%200l2.394-2.38a4.275%204.275%200%200%200%200-6.072Zm-13.683%2013.6a4.337%204.337%200%200%200%206.108%200l1.262-1.255a.993.993%200%200%200%200-1.41%201.007%201.007%200%200%200-1.418%200L9.954%2017.45a2.323%202.323%200%200%201-3.27%200%202.29%202.29%200%200%201%200-3.251l2.344-2.331a2.579%202.579%200%200%201%203.631%200c.392.39%201.027.39%201.419%200a.993.993%200%200%200%200-1.41%204.593%204.593%200%200%200-6.468%200l-2.345%202.33a4.275%204.275%200%200%200%200%206.072Z%22%20fill%3D%22%23000%22%2F%3E%3C%2Fsvg%3E"); -} -trix-toolbar .trix-button--icon-strike::before { - background-image: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M6%2014.986c.088%202.647%202.246%204.258%205.635%204.258%203.496%200%205.713-1.728%205.713-4.463%200-.275-.02-.536-.062-.781h-3.461c.398.293.573.654.573%201.123%200%201.035-1.074%201.787-2.646%201.787-1.563%200-2.773-.762-2.91-1.924H6ZM6.432%2010h3.763c-.632-.314-.914-.715-.914-1.273%200-1.045.977-1.739%202.432-1.739%201.475%200%202.52.723%202.617%201.914h2.764c-.05-2.548-2.11-4.238-5.39-4.238-3.145%200-5.392%201.719-5.392%204.316%200%20.363.04.703.12%201.02ZM4%2011a1%201%200%201%200%200%202h15a1%201%200%201%200%200-2H4Z%22%20fill%3D%22%23000%22%2F%3E%3C%2Fsvg%3E"); -} -trix-toolbar .trix-button--icon-quote::before { - background-image: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20d%3D%22M4.581%208.471c.44-.5%201.056-.834%201.758-.995C8.074%207.17%209.201%207.822%2010%208.752c1.354%201.578%201.33%203.555.394%205.277-.941%201.731-2.788%203.163-4.988%203.56a.622.622%200%200%201-.653-.317c-.113-.205-.121-.49.16-.764.294-.286.567-.566.791-.835.222-.266.413-.54.524-.815.113-.28.156-.597.026-.908-.128-.303-.39-.524-.72-.69a3.02%203.02%200%200%201-1.674-2.7c0-.905.283-1.59.72-2.088Zm9.419%200c.44-.5%201.055-.834%201.758-.995%201.734-.306%202.862.346%203.66%201.276%201.355%201.578%201.33%203.555.395%205.277-.941%201.731-2.789%203.163-4.988%203.56a.622.622%200%200%201-.653-.317c-.113-.205-.122-.49.16-.764.294-.286.567-.566.791-.835.222-.266.412-.54.523-.815.114-.28.157-.597.026-.908-.127-.303-.39-.524-.72-.69a3.02%203.02%200%200%201-1.672-2.701c0-.905.283-1.59.72-2.088Z%22%20fill%3D%22%23000%22%2F%3E%3C%2Fsvg%3E"); -} -trix-toolbar .trix-button--icon-heading-1::before { - background-image: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M21.5%207.5v-3h-12v3H14v13h3v-13h4.5ZM9%2013.5h3.5v-3h-10v3H6v7h3v-7Z%22%20fill%3D%22%23000%22%2F%3E%3C%2Fsvg%3E"); -} -trix-toolbar .trix-button--icon-code::before { - background-image: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M3.293%2011.293a1%201%200%200%200%200%201.414l4%204a1%201%200%201%200%201.414-1.414L5.414%2012l3.293-3.293a1%201%200%200%200-1.414-1.414l-4%204Zm13.414%205.414%204-4a1%201%200%200%200%200-1.414l-4-4a1%201%200%201%200-1.414%201.414L18.586%2012l-3.293%203.293a1%201%200%200%200%201.414%201.414Z%22%20fill%3D%22%23000%22%2F%3E%3C%2Fsvg%3E"); -} -trix-toolbar .trix-button--icon-bullet-list::before { - background-image: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M5%207.5a1.5%201.5%200%201%200%200-3%201.5%201.5%200%200%200%200%203ZM8%206a1%201%200%200%201%201-1h11a1%201%200%201%201%200%202H9a1%201%200%200%201-1-1Zm1%205a1%201%200%201%200%200%202h11a1%201%200%201%200%200-2H9Zm0%206a1%201%200%201%200%200%202h11a1%201%200%201%200%200-2H9Zm-2.5-5a1.5%201.5%200%201%201-3%200%201.5%201.5%200%200%201%203%200ZM5%2019.5a1.5%201.5%200%201%200%200-3%201.5%201.5%200%200%200%200%203Z%22%20fill%3D%22%23000%22%2F%3E%3C%2Fsvg%3E"); -} -trix-toolbar .trix-button--icon-number-list::before { - background-image: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M3%204h2v4H4V5H3V4Zm5%202a1%201%200%200%201%201-1h11a1%201%200%201%201%200%202H9a1%201%200%200%201-1-1Zm1%205a1%201%200%201%200%200%202h11a1%201%200%201%200%200-2H9Zm0%206a1%201%200%201%200%200%202h11a1%201%200%201%200%200-2H9Zm-3.5-7H6v1l-1.5%202H6v1H3v-1l1.667-2H3v-1h2.5ZM3%2017v-1h3v4H3v-1h2v-.5H4v-1h1V17H3Z%22%20fill%3D%22%23000%22%2F%3E%3C%2Fsvg%3E"); -} -trix-toolbar .trix-button--icon-undo::before { - background-image: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M3%2014a1%201%200%200%200%201%201h6a1%201%200%201%200%200-2H6.257c2.247-2.764%205.151-3.668%207.579-3.264%202.589.432%204.739%202.356%205.174%205.405a1%201%200%200%200%201.98-.283c-.564-3.95-3.415-6.526-6.825-7.095C11.084%207.25%207.63%208.377%205%2011.39V8a1%201%200%200%200-2%200v6Zm2-1Z%22%20fill%3D%22%23000%22%2F%3E%3C%2Fsvg%3E"); -} -trix-toolbar .trix-button--icon-redo::before { - background-image: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M21%2014a1%201%200%200%201-1%201h-6a1%201%200%201%201%200-2h3.743c-2.247-2.764-5.151-3.668-7.579-3.264-2.589.432-4.739%202.356-5.174%205.405a1%201%200%200%201-1.98-.283c.564-3.95%203.415-6.526%206.826-7.095%203.08-.513%206.534.614%209.164%203.626V8a1%201%200%201%201%202%200v6Zm-2-1Z%22%20fill%3D%22%23000%22%2F%3E%3C%2Fsvg%3E"); -} -trix-toolbar .trix-button--icon-decrease-nesting-level::before { - background-image: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M5%206a1%201%200%200%201%201-1h12a1%201%200%201%201%200%202H6a1%201%200%200%201-1-1Zm4%205a1%201%200%201%200%200%202h9a1%201%200%201%200%200-2H9Zm-3%206a1%201%200%201%200%200%202h12a1%201%200%201%200%200-2H6Zm-3.707-5.707a1%201%200%200%200%200%201.414l2%202a1%201%200%201%200%201.414-1.414L4.414%2012l1.293-1.293a1%201%200%200%200-1.414-1.414l-2%202Z%22%20fill%3D%22%23000%22%2F%3E%3C%2Fsvg%3E"); -} -trix-toolbar .trix-button--icon-increase-nesting-level::before { - background-image: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M5%206a1%201%200%200%201%201-1h12a1%201%200%201%201%200%202H6a1%201%200%200%201-1-1Zm4%205a1%201%200%201%200%200%202h9a1%201%200%201%200%200-2H9Zm-3%206a1%201%200%201%200%200%202h12a1%201%200%201%200%200-2H6Zm-2.293-2.293%202-2a1%201%200%200%200%200-1.414l-2-2a1%201%200%201%200-1.414%201.414L3.586%2012l-1.293%201.293a1%201%200%201%200%201.414%201.414Z%22%20fill%3D%22%23000%22%2F%3E%3C%2Fsvg%3E"); -} -trix-toolbar .trix-dialogs { - position: relative; -} -trix-toolbar .trix-dialog { - position: absolute; - top: 0; - left: 0; - right: 0; - font-size: 0.75em; - padding: 15px 10px; - background: #fff; - box-shadow: 0 0.3em 1em #ccc; - border-top: 2px solid #888; - border-radius: 5px; - z-index: 5; -} -trix-toolbar .trix-input--dialog { - font-size: inherit; - font-weight: normal; - padding: 0.5em 0.8em; - margin: 0 10px 0 0; - border-radius: 3px; - border: 1px solid #bbb; - background-color: #fff; - box-shadow: none; - outline: none; - -webkit-appearance: none; - -moz-appearance: none; -} -trix-toolbar .trix-input--dialog.validate:invalid { - box-shadow: #F00 0px 0px 1.5px 1px; -} -trix-toolbar .trix-button--dialog { - font-size: inherit; - padding: 0.5em; - border-bottom: none; -} -trix-toolbar .trix-dialog--link { - max-width: 600px; -} -trix-toolbar .trix-dialog__link-fields { - display: flex; - align-items: baseline; -} -trix-toolbar .trix-dialog__link-fields .trix-input { - flex: 1; -} -trix-toolbar .trix-dialog__link-fields .trix-button-group { - flex: 0 0 content; - margin: 0; -} - -trix-editor [data-trix-mutable]:not(.attachment__caption-editor) { - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; -} - -trix-editor [data-trix-mutable] ::-moz-selection, trix-editor [data-trix-mutable]::-moz-selection, -trix-editor [data-trix-cursor-target]::-moz-selection { - background: none; -} -trix-editor [data-trix-mutable] ::selection, trix-editor [data-trix-mutable]::selection, -trix-editor [data-trix-cursor-target]::selection { - background: none; -} - -trix-editor [data-trix-mutable].attachment__caption-editor:focus::-moz-selection { - background: highlight; -} -trix-editor [data-trix-mutable].attachment__caption-editor:focus::selection { - background: highlight; -} - -trix-editor [data-trix-mutable].attachment.attachment--file { - box-shadow: 0 0 0 2px highlight; - border-color: transparent; -} -trix-editor [data-trix-mutable].attachment img { - box-shadow: 0 0 0 2px highlight; -} -trix-editor .attachment { - position: relative; -} -trix-editor .attachment:hover { - cursor: default; -} -trix-editor .attachment--preview .attachment__caption:hover { - cursor: text; -} -trix-editor .attachment__progress { - position: absolute; - z-index: 1; - height: 20px; - top: calc(50% - 10px); - left: 5%; - width: 90%; - opacity: 0.9; - transition: opacity 200ms ease-in; -} -trix-editor .attachment__progress[value="100"] { - opacity: 0; -} -trix-editor .attachment__caption-editor { - display: inline-block; - width: 100%; - margin: 0; - padding: 0; - font-size: inherit; - font-family: inherit; - line-height: inherit; - color: inherit; - text-align: center; - vertical-align: top; - border: none; - outline: none; - -webkit-appearance: none; - -moz-appearance: none; -} -trix-editor .attachment__toolbar { - position: absolute; - z-index: 1; - top: -0.9em; - left: 0; - width: 100%; - text-align: center; -} -trix-editor .trix-button-group { - display: inline-flex; -} -trix-editor .trix-button { - position: relative; - float: left; - color: #666; - white-space: nowrap; - font-size: 80%; - padding: 0 0.8em; - margin: 0; - outline: none; - border: none; - border-radius: 0; - background: transparent; -} -trix-editor .trix-button:not(:first-child) { - border-left: 1px solid #ccc; -} -trix-editor .trix-button.trix-active { - background: #cbeefa; -} -trix-editor .trix-button:not(:disabled) { - cursor: pointer; -} -trix-editor .trix-button--remove { - text-indent: -9999px; - display: inline-block; - padding: 0; - outline: none; - width: 1.8em; - height: 1.8em; - line-height: 1.8em; - border-radius: 50%; - background-color: #fff; - border: 2px solid highlight; - box-shadow: 1px 1px 6px rgba(0, 0, 0, 0.25); -} -trix-editor .trix-button--remove::before { - display: inline-block; - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; - opacity: 0.7; - content: ""; - background-image: url("data:image/svg+xml,%3Csvg%20height%3D%2224%22%20width%3D%2224%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20d%3D%22M19%206.41%2017.59%205%2012%2010.59%206.41%205%205%206.41%2010.59%2012%205%2017.59%206.41%2019%2012%2013.41%2017.59%2019%2019%2017.59%2013.41%2012z%22%2F%3E%3Cpath%20d%3D%22M0%200h24v24H0z%22%20fill%3D%22none%22%2F%3E%3C%2Fsvg%3E"); - background-position: center; - background-repeat: no-repeat; - background-size: 90%; -} -trix-editor .trix-button--remove:hover { - border-color: #333; -} -trix-editor .trix-button--remove:hover::before { - opacity: 1; -} -trix-editor .attachment__metadata-container { - position: relative; -} -trix-editor .attachment__metadata { - position: absolute; - left: 50%; - top: 2em; - transform: translate(-50%, 0); - max-width: 90%; - padding: 0.1em 0.6em; - font-size: 0.8em; - color: #fff; - background-color: rgba(0, 0, 0, 0.7); - border-radius: 3px; -} -trix-editor .attachment__metadata .attachment__name { - display: inline-block; - max-width: 100%; - vertical-align: bottom; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} -trix-editor .attachment__metadata .attachment__size { - margin-left: 0.2em; - white-space: nowrap; -} - -.trix-content { - line-height: 1.5; - overflow-wrap: break-word; - word-break: break-word; -} -.trix-content * { - box-sizing: border-box; - margin: 0; - padding: 0; -} -.trix-content h1 { - font-size: 1.2em; - line-height: 1.2; -} -.trix-content blockquote { - border: 0 solid #ccc; - border-left-width: 0.3em; - margin-left: 0.3em; - padding-left: 0.6em; -} -.trix-content [dir=rtl] blockquote, -.trix-content blockquote[dir=rtl] { - border-width: 0; - border-right-width: 0.3em; - margin-right: 0.3em; - padding-right: 0.6em; -} -.trix-content li { - margin-left: 1em; -} -.trix-content [dir=rtl] li { - margin-right: 1em; -} -.trix-content pre { - display: inline-block; - width: 100%; - vertical-align: top; - font-family: monospace; - font-size: 0.9em; - padding: 0.5em; - white-space: pre; - background-color: #eee; - overflow-x: auto; -} -.trix-content img { - max-width: 100%; - height: auto; -} -.trix-content .attachment { - display: inline-block; - position: relative; - max-width: 100%; -} -.trix-content .attachment a { - color: inherit; - text-decoration: none; -} -.trix-content .attachment a:hover, .trix-content .attachment a:visited:hover { - color: inherit; -} -.trix-content .attachment__caption { - text-align: center; -} -.trix-content .attachment__caption .attachment__name + .attachment__size::before { - content: " •"; -} -.trix-content .attachment--preview { - width: 100%; - text-align: center; -} -.trix-content .attachment--preview .attachment__caption { - color: #666; - font-size: 0.9em; - line-height: 1.2; -} -.trix-content .attachment--file { - color: #333; - line-height: 1; - margin: 0 2px 2px 2px; - padding: 0.4em 1em; - border: 1px solid #bbb; - border-radius: 5px; -} -.trix-content .attachment-gallery { - display: flex; - flex-wrap: wrap; - position: relative; -} -.trix-content .attachment-gallery .attachment { - flex: 1 0 33%; - padding: 0 0.5em; - max-width: 33%; -} -.trix-content .attachment-gallery.attachment-gallery--2 .attachment, .trix-content .attachment-gallery.attachment-gallery--4 .attachment { - flex-basis: 50%; - max-width: 50%; -} \ No newline at end of file diff --git a/actiontext/lib/action_text/engine.rb b/actiontext/lib/action_text/engine.rb index ae524dd8bb504..77fdcb3f1207e 100644 --- a/actiontext/lib/action_text/engine.rb +++ b/actiontext/lib/action_text/engine.rb @@ -8,6 +8,7 @@ require "active_storage/engine" require "action_text" +require "action_text/trix" module ActionText class Engine < Rails::Engine @@ -34,7 +35,7 @@ class Engine < Rails::Engine initializer "action_text.asset" do if Rails.application.config.respond_to?(:assets) - Rails.application.config.assets.precompile += %w( actiontext.js actiontext.esm.js trix.js trix.css ) + Rails.application.config.assets.precompile += %w( actiontext.js actiontext.esm.js ) end end diff --git a/railties/test/railties/engine_test.rb b/railties/test/railties/engine_test.rb index c9f1b5ecb51b9..b737a933449dd 100644 --- a/railties/test/railties/engine_test.rb +++ b/railties/test/railties/engine_test.rb @@ -1545,6 +1545,7 @@ def bar ActiveStorage::Engine ActionCable::Engine ActionMailbox::Engine + Trix::Engine ActionText::Engine Bukkits::Engine Importmap::Engine From c060bb242ab8decbef7a147cbe2139cef06b7302 Mon Sep 17 00:00:00 2001 From: fatkodima Date: Thu, 6 Feb 2025 13:12:39 +0200 Subject: [PATCH 0165/1075] Fix `retry_job` instrumentation when using `:test` adapter for Active Job --- activejob/lib/active_job/exceptions.rb | 3 ++- activejob/test/cases/logging_test.rb | 10 ++++++++++ activejob/test/jobs/retry_job.rb | 2 ++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/activejob/lib/active_job/exceptions.rb b/activejob/lib/active_job/exceptions.rb index 3dccd1a2423f7..6433024683942 100644 --- a/activejob/lib/active_job/exceptions.rb +++ b/activejob/lib/active_job/exceptions.rb @@ -157,7 +157,8 @@ def after_discard(&blk) # end def retry_job(options = {}) instrument :enqueue_retry, options.slice(:error, :wait) do - enqueue options + job = dup + job.enqueue options end end diff --git a/activejob/test/cases/logging_test.rb b/activejob/test/cases/logging_test.rb index 33a0a3ddc0657..aa06edf8938e0 100644 --- a/activejob/test/cases/logging_test.rb +++ b/activejob/test/cases/logging_test.rb @@ -309,6 +309,16 @@ def test_enqueue_retry_logging assert_match(/Retrying RetryJob \(Job ID: .*?\) after \d+ attempts in 3 seconds, due to a DefaultsError.*\./, @logger.messages) end end + + def test_retry_different_queue_logging + perform_enqueued_jobs do + perform_enqueued_jobs do + RetryJob.perform_later("HeavyError", 2) + assert_match(/Performed RetryJob \(Job ID: .*?\) from .+\(default\) in .*ms/, @logger.messages) + end + assert_match(/Performed RetryJob \(Job ID: .*?\) from .+\(low\) in .*ms/, @logger.messages) + end + end end def test_enqueue_retry_logging_on_retry_job diff --git a/activejob/test/jobs/retry_job.rb b/activejob/test/jobs/retry_job.rb index 0a8f716346591..fbfeca6c5c944 100644 --- a/activejob/test/jobs/retry_job.rb +++ b/activejob/test/jobs/retry_job.rb @@ -19,6 +19,7 @@ class SecondDiscardableErrorOfTwo < StandardError; end class CustomDiscardableError < StandardError; end class UnlimitedRetryError < StandardError; end class ReportedError < StandardError; end +class HeavyError < StandardError; end class RetryJob < ActiveJob::Base retry_on DefaultsError @@ -33,6 +34,7 @@ class RetryJob < ActiveJob::Base retry_on(ActiveJob::DeserializationError) { |job, error| JobBuffer.add("Raised #{error.class} for the #{job.executions} time") } retry_on UnlimitedRetryError, attempts: :unlimited retry_on ReportedError, report: true + retry_on HeavyError, queue: :low discard_on DiscardableError discard_on FirstDiscardableErrorOfTwo, SecondDiscardableErrorOfTwo From 44324036ee0540908f3d1ed53695b95450c74b5d Mon Sep 17 00:00:00 2001 From: Jenny Shen Date: Wed, 14 May 2025 14:41:35 -0400 Subject: [PATCH 0166/1075] Fix affected_rows for SQLite sql.active_record notifications SQLite's connection.changes only reflects the number of rows affected by the last DELETE/UPDATE/INSERT, and the number would not change for any other query. Therefore, SELECT statements would have the affected row count of a previous query. To solve this, we are now calculating the different between the total changes before and after the query has been executed. Co-authored-by: Hartley McGuire --- .../connection_adapters/sqlite3/database_statements.rb | 8 +++++--- activerecord/test/cases/instrumentation_test.rb | 8 +++++++- 2 files changed, 12 insertions(+), 4 deletions(-) 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 03d78a6f7e793..d6e027b40c23b 100644 --- a/activerecord/lib/active_record/connection_adapters/sqlite3/database_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/sqlite3/database_statements.rb @@ -84,6 +84,8 @@ def internal_begin_transaction(mode, isolation) end def perform_query(raw_connection, sql, binds, type_casted_binds, prepare:, notification_payload:, batch: false) + total_changes_before_query = raw_connection.total_changes + if batch raw_connection.execute_batch2(sql) elsif prepare @@ -114,10 +116,10 @@ def perform_query(raw_connection, sql, binds, type_casted_binds, prepare:, notif stmt.close end end - @last_affected_rows = raw_connection.changes + @affected_rows = raw_connection.total_changes - total_changes_before_query verified! - notification_payload[:affected_rows] = @last_affected_rows + notification_payload[:affected_rows] = @affected_rows notification_payload[:row_count] = result&.length || 0 result end @@ -129,7 +131,7 @@ def cast_result(result) end def affected_rows(result) - @last_affected_rows + @affected_rows end def execute_batch(statements, name = nil, **kwargs) diff --git a/activerecord/test/cases/instrumentation_test.rb b/activerecord/test/cases/instrumentation_test.rb index 53315af1640ac..94b3781ee44fd 100644 --- a/activerecord/test/cases/instrumentation_test.rb +++ b/activerecord/test/cases/instrumentation_test.rb @@ -165,6 +165,8 @@ def test_payload_affected_rows # INSERT ... RETURNING Book.insert_all!([{ name: "One" }, { name: "Two" }, { name: "Three" }, { name: "Four" }], returning: false) + Book.where(name: ["One", "Two", "Three"]).pluck(:id) + Book.where(name: ["One", "Two", "Three"]).update_all(status: :published) Book.where(name: ["Three", "Four"]).delete_all @@ -172,7 +174,11 @@ def test_payload_affected_rows Book.where(name: ["Three", "Four"]).delete_all end - assert_equal [4, 3, 2, 0], affected_row_values + assert_equal 4, affected_row_values.first + assert_not_equal affected_row_values.first, affected_row_values.second + assert_equal 3, affected_row_values.third + assert_equal 2, affected_row_values.fourth + assert_equal 0, affected_row_values.fifth end def test_no_instantiation_notification_when_no_records From 169f7d54314e4cf6d40514b1e688433c1005a63b Mon Sep 17 00:00:00 2001 From: fatkodima Date: Tue, 15 Apr 2025 15:00:05 +0300 Subject: [PATCH 0167/1075] Fix `config.active_storage.touch_attachment_records` to work with eager loading --- activestorage/lib/active_storage/engine.rb | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/activestorage/lib/active_storage/engine.rb b/activestorage/lib/active_storage/engine.rb index 77b3ff8990958..3ae9254429f1e 100644 --- a/activestorage/lib/active_storage/engine.rb +++ b/activestorage/lib/active_storage/engine.rb @@ -82,6 +82,10 @@ class Engine < Rails::Engine # :nodoc: end initializer "active_storage.configs" do + config.before_initialize do |app| + ActiveStorage.touch_attachment_records = app.config.active_storage.touch_attachment_records != false + end + config.after_initialize do |app| ActiveStorage.logger = app.config.active_storage.logger || Rails.logger ActiveStorage.variant_processor = app.config.active_storage.variant_processor @@ -144,7 +148,6 @@ class Engine < Rails::Engine # :nodoc: ActiveStorage.variable_content_types = app.config.active_storage.variable_content_types || [] ActiveStorage.web_image_content_types = app.config.active_storage.web_image_content_types || [] ActiveStorage.content_types_to_serve_as_binary = app.config.active_storage.content_types_to_serve_as_binary || [] - ActiveStorage.touch_attachment_records = app.config.active_storage.touch_attachment_records != false ActiveStorage.service_urls_expire_in = app.config.active_storage.service_urls_expire_in || 5.minutes ActiveStorage.urls_expire_in = app.config.active_storage.urls_expire_in ActiveStorage.content_types_allowed_inline = app.config.active_storage.content_types_allowed_inline || [] From 9ee0d93fc378163a44ed3085d2641b65408508b8 Mon Sep 17 00:00:00 2001 From: Andrey Sobolev Date: Thu, 15 May 2025 17:28:18 +0300 Subject: [PATCH 0168/1075] Fix typos in The Asset Pipeline guide --- guides/source/asset_pipeline.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/guides/source/asset_pipeline.md b/guides/source/asset_pipeline.md index 7ff662020b794..2b0ca5e0e5e13 100644 --- a/guides/source/asset_pipeline.md +++ b/guides/source/asset_pipeline.md @@ -174,7 +174,7 @@ paths](#digested-assets-in-views) using helpers like `asset_path`, `image_tag`, automatically converted into their fingerprinted paths using the [`.manifest.json` file](#manifest-files). -Its possible to exclude certain directories from this process, you can read more +It is possible to exclude certain directories from this process, you can read more about it in the [Fingerprinting section](#fingerprinting-versioning-with-digest-based-urls). @@ -359,7 +359,7 @@ serve them efficiently. ### Setup -Follow these steps for setup Propshaft in your Rails application: +Follow these steps for setting up Propshaft in your Rails application: 1. Create a new Rails application: @@ -542,7 +542,7 @@ headers. For Apache: ```apache -# The Expires* directives requires the Apache module +# The Expires* directives require the Apache module # `mod_expires` to be enabled. # Use of ETag is discouraged when Last-Modified is present @@ -642,7 +642,7 @@ it will try to find it at the "origin" `example.com/assets/smile.png`, and then store it for future use. If you want to serve only some assets from your CDN, you can use custom `:host` -option your asset helper, which overwrites value set in +option for your asset helper, which overwrites the value set in [`config.action_controller.asset_host`][]. ```erb @@ -1013,7 +1013,7 @@ delivered via the Rails asset pipeline. 4. In production, the gem ensures your stylesheets are compiled and ready for deployment. During the `assets:precompile` step, it installs all `package.json` dependencies via `bun`, `yarn`, `pnpm` or `npm` and runs the - `build:css` task. to process your stylesheet entry points. The resulting CSS + `build:css` task to process your stylesheet entry points. The resulting CSS output is then digested by the asset pipeline and copied into the `public/assets` directory, just like other asset pipeline files. From 88ef03632490f9dcecf60ca8219d662fd8e42ce4 Mon Sep 17 00:00:00 2001 From: Caroline Date: Thu, 15 May 2025 08:42:46 -0600 Subject: [PATCH 0169/1075] Fix typo on active record querying running explain --- guides/source/active_record_querying.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/guides/source/active_record_querying.md b/guides/source/active_record_querying.md index c244b02c43d4b..f02eafddaca1a 100644 --- a/guides/source/active_record_querying.md +++ b/guides/source/active_record_querying.md @@ -2635,9 +2635,9 @@ EXPLAIN SELECT `customers`.* FROM `customers` INNER JOIN `orders` ON `orders`.`c 2 rows in set (0.00 sec) ``` -Active Record performs a pretty printing that emulates that of the -corresponding database shell. So, the same query running with the -PostgreSQL adapter would yield instead: +Active Record performs pretty printing that emulates the output of +the corresponding database shell. So, the same query run with the +PostgreSQL adapter would instead yield: ```sql EXPLAIN SELECT "customers".* FROM "customers" INNER JOIN "orders" ON "orders"."customer_id" = "customers"."id" WHERE "customers"."id" = $1 [["id", 1]] From d4f8806344bd554a4f0703620af7429667c0aa6d Mon Sep 17 00:00:00 2001 From: Jenny Shen Date: Tue, 13 May 2025 16:51:11 -0400 Subject: [PATCH 0170/1075] Add affected_rows to ActiveRecord::Result For database adapters that do not support RETURNING, insert_all and upsert_all does not provide what rows has been changed. Adding affected_rows to the result allows this information to be accessed like other bulk operations (like delete_all and update_all) --- activerecord/CHANGELOG.md | 4 +++ .../mysql2/database_statements.rb | 4 +-- .../postgresql/database_statements.rb | 22 +++++++-------- .../sqlite3/database_statements.rb | 28 ++++++++----------- .../trilogy/database_statements.rb | 4 +-- activerecord/lib/active_record/result.rb | 26 ++++++++--------- activerecord/test/cases/adapter_test.rb | 17 +++++++++++ activerecord/test/cases/result_test.rb | 4 ++- 8 files changed, 64 insertions(+), 45 deletions(-) diff --git a/activerecord/CHANGELOG.md b/activerecord/CHANGELOG.md index e37f23f049a76..594321408d2c1 100644 --- a/activerecord/CHANGELOG.md +++ b/activerecord/CHANGELOG.md @@ -1,3 +1,7 @@ +* Add `affected_rows` to `ActiveRecord::Result`. + + *Jenny Shen* + * Enable passing retryable SqlLiterals to `#where`. *Hartley McGuire* diff --git a/activerecord/lib/active_record/connection_adapters/mysql2/database_statements.rb b/activerecord/lib/active_record/connection_adapters/mysql2/database_statements.rb index dab0da3fbd874..ff4321b0f4f24 100644 --- a/activerecord/lib/active_record/connection_adapters/mysql2/database_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/mysql2/database_statements.rb @@ -114,12 +114,12 @@ def perform_query(raw_connection, sql, binds, type_casted_binds, prepare:, notif end def cast_result(raw_result) - return ActiveRecord::Result.empty if raw_result.nil? + return ActiveRecord::Result.empty(affected_rows: @affected_rows_before_warnings) if raw_result.nil? fields = raw_result.fields result = if fields.empty? - ActiveRecord::Result.empty + ActiveRecord::Result.empty(affected_rows: @affected_rows_before_warnings) else ActiveRecord::Result.new(fields, raw_result.to_a) end 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 47a7ecb47440b..6d07c2f73273d 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/database_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/database_statements.rb @@ -170,20 +170,20 @@ def perform_query(raw_connection, sql, binds, type_casted_binds, prepare:, notif end def cast_result(result) - if result.fields.empty? - result.clear - return ActiveRecord::Result.empty - end + ar_result = if result.fields.empty? + ActiveRecord::Result.empty(affected_rows: result.cmd_tuples) + else + fields = result.fields + types = Array.new(fields.size) + fields.size.times do |index| + ftype = result.ftype(index) + fmod = result.fmod(index) + types[index] = get_oid_type(ftype, fmod, fields[index]) + end - fields = result.fields - types = Array.new(fields.size) - fields.size.times do |index| - ftype = result.ftype(index) - fmod = result.fmod(index) - types[index] = get_oid_type(ftype, fmod, fields[index]) + ActiveRecord::Result.new(fields, result.values, types.freeze, affected_rows: result.cmd_tuples) end - ar_result = ActiveRecord::Result.new(fields, result.values, types.freeze) result.clear ar_result end 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 d6e027b40c23b..c550681bcf6d7 100644 --- a/activerecord/lib/active_record/connection_adapters/sqlite3/database_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/sqlite3/database_statements.rb @@ -88,35 +88,31 @@ def perform_query(raw_connection, sql, binds, type_casted_binds, prepare:, notif if batch raw_connection.execute_batch2(sql) - elsif prepare - stmt = @statements[sql] ||= raw_connection.prepare(sql) - stmt.reset! - stmt.bind_params(type_casted_binds) - - result = if stmt.column_count.zero? # No return - stmt.step - ActiveRecord::Result.empty + else + stmt = if prepare + @statements[sql] ||= raw_connection.prepare(sql) + @statements[sql].reset! else - ActiveRecord::Result.new(stmt.columns, stmt.to_a, stmt.types.map { |t| type_map.lookup(t) }) + # Don't cache statements if they are not prepared. + raw_connection.prepare(sql) end - else - # Don't cache statements if they are not prepared. - stmt = raw_connection.prepare(sql) begin unless binds.nil? || binds.empty? stmt.bind_params(type_casted_binds) end result = if stmt.column_count.zero? # No return stmt.step - ActiveRecord::Result.empty + @affected_rows = raw_connection.total_changes - total_changes_before_query + ActiveRecord::Result.empty(affected_rows: @affected_rows) else - ActiveRecord::Result.new(stmt.columns, stmt.to_a, stmt.types.map { |t| type_map.lookup(t) }) + rows = stmt.to_a + @affected_rows = raw_connection.total_changes - total_changes_before_query + ActiveRecord::Result.new(stmt.columns, rows, stmt.types.map { |t| type_map.lookup(t) }, affected_rows: @affected_rows) end ensure - stmt.close + stmt.close unless prepare end end - @affected_rows = raw_connection.total_changes - total_changes_before_query verified! notification_payload[:affected_rows] = @affected_rows diff --git a/activerecord/lib/active_record/connection_adapters/trilogy/database_statements.rb b/activerecord/lib/active_record/connection_adapters/trilogy/database_statements.rb index d59210b792bda..3082711affc78 100644 --- a/activerecord/lib/active_record/connection_adapters/trilogy/database_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/trilogy/database_statements.rb @@ -41,9 +41,9 @@ def perform_query(raw_connection, sql, binds, type_casted_binds, prepare:, notif def cast_result(result) if result.fields.empty? - ActiveRecord::Result.empty + ActiveRecord::Result.empty(affected_rows: result.affected_rows) else - ActiveRecord::Result.new(result.fields, result.rows) + ActiveRecord::Result.new(result.fields, result.rows, affected_rows: result.affected_rows) end end diff --git a/activerecord/lib/active_record/result.rb b/activerecord/lib/active_record/result.rb index adc1436b8cfba..f540a00314d65 100644 --- a/activerecord/lib/active_record/result.rb +++ b/activerecord/lib/active_record/result.rb @@ -29,6 +29,11 @@ module ActiveRecord # ... # ] # + # # Get the number of rows affected by the query: + # result = ActiveRecord::Base.lease_connection.exec_query('INSERT INTO posts (title, body) VALUES ("title_3", "body_3"), ("title_4", "body_4")') + # result.affected_rows + # # => 2 + # # # ActiveRecord::Result also includes Enumerable. # result.each do |row| # puts row['title'] + " " + row['body'] @@ -89,17 +94,17 @@ def to_h alias_method :to_hash, :to_h end - attr_reader :columns, :rows + attr_reader :columns, :rows, :affected_rows - def self.empty(async: false) # :nodoc: + def self.empty(async: false, affected_rows: nil) # :nodoc: if async - EMPTY_ASYNC + FutureResult.wrap(new(EMPTY_ARRAY, EMPTY_ARRAY, EMPTY_HASH, affected_rows: affected_rows)).freeze else - EMPTY + new(EMPTY_ARRAY, EMPTY_ARRAY, EMPTY_HASH, affected_rows: affected_rows).freeze end end - def initialize(columns, rows, column_types = nil) + def initialize(columns, rows, column_types = nil, affected_rows: nil) # We freeze the strings to prevent them getting duped when # used as keys in ActiveRecord::Base's @attributes hash @columns = columns.each(&:-@).freeze @@ -108,6 +113,7 @@ def initialize(columns, rows, column_types = nil) @column_types = column_types.freeze @types_hash = nil @column_indexes = nil + @affected_rows = affected_rows end # Returns true if this result set includes the column named +name+ @@ -260,14 +266,8 @@ def hash_rows end end - empty_array = [].freeze + EMPTY_ARRAY = [].freeze EMPTY_HASH = {}.freeze - private_constant :EMPTY_HASH - - EMPTY = new(empty_array, empty_array, EMPTY_HASH).freeze - private_constant :EMPTY - - EMPTY_ASYNC = FutureResult.wrap(EMPTY).freeze - private_constant :EMPTY_ASYNC + private_constant :EMPTY_ARRAY, :EMPTY_HASH end end diff --git a/activerecord/test/cases/adapter_test.rb b/activerecord/test/cases/adapter_test.rb index 09d640de2cf06..0678530c95196 100644 --- a/activerecord/test/cases/adapter_test.rb +++ b/activerecord/test/cases/adapter_test.rb @@ -153,6 +153,23 @@ def test_current_database assert_not_empty result.columns end + test "#exec_query queries return an ActiveRecord::Result with affected rows" do + result = @connection.exec_query "INSERT INTO subscribers(nick, name) VALUES('me', 'me'), ('you', 'you')" + assert_equal 2, result.affected_rows + + update_result = @connection.exec_query "UPDATE subscribers SET name = 'you' WHERE name = 'me'" + assert_equal 1, update_result.affected_rows + + select_result = @connection.exec_query "SELECT * FROM subscribers" + assert_not_equal update_result.affected_rows, select_result.affected_rows + + result = @connection.exec_query "DELETE FROM subscribers WHERE name = 'you'" + assert_equal 2, result.affected_rows + + result = @connection.exec_query "DELETE FROM subscribers WHERE name = 'you'" + assert_equal 0, result.affected_rows + end + if current_adapter?(:Mysql2Adapter, :TrilogyAdapter) def test_charset assert_not_nil @connection.charset diff --git a/activerecord/test/cases/result_test.rb b/activerecord/test/cases/result_test.rb index 90b54b48e986b..7b00e7f9a1bab 100644 --- a/activerecord/test/cases/result_test.rb +++ b/activerecord/test/cases/result_test.rb @@ -9,7 +9,7 @@ def result ["row 1 col 1", "row 1 col 2"], ["row 2 col 1", "row 2 col 2"], ["row 3 col 1", "row 3 col 2"], - ]) + ], affected_rows: 3) end test "includes_column?" do @@ -138,6 +138,7 @@ def result assert_equal a.columns, b.columns assert_equal a.rows, b.rows assert_equal a.column_indexes, b.column_indexes + assert_equal a.affected_rows, b.affected_rows # Second round in case of mutation b = b.dup @@ -146,6 +147,7 @@ def result assert_equal a.columns, b.columns assert_equal a.rows, b.rows assert_equal a.column_indexes, b.column_indexes + assert_equal a.affected_rows, b.affected_rows end test "column_types handles nil types in the column_types array" do From 0109d9d68a6b0ae3397bb69c5e2f6283c0d138e1 Mon Sep 17 00:00:00 2001 From: Chris Oliver Date: Thu, 15 May 2025 12:47:09 -0500 Subject: [PATCH 0171/1075] [ci skip][docs] Keep Product model associations consistent --- guides/source/getting_started.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/guides/source/getting_started.md b/guides/source/getting_started.md index e6455375e0762..bd611145aa510 100644 --- a/guides/source/getting_started.md +++ b/guides/source/getting_started.md @@ -2261,9 +2261,9 @@ emails anytime the inventory count changes from 0 to a positive number. ```ruby#9-19 class Product < ApplicationRecord + has_many :subscribers, dependent: :destroy has_one_attached :featured_image has_rich_text :description - has_many :subscribers, dependent: :destroy validates :name, presence: true validates :inventory_count, numericality: { greater_than_or_equal_to: 0 } From fcf01b8820b6e4c3bd70ae3cc653229bbe5a1fba Mon Sep 17 00:00:00 2001 From: zzak Date: Fri, 16 May 2025 09:51:04 +0900 Subject: [PATCH 0172/1075] Parallelize takes :number_of_processors as the option [ci skip] --- activesupport/lib/active_support/test_case.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/activesupport/lib/active_support/test_case.rb b/activesupport/lib/active_support/test_case.rb index f13c39506aab3..130685a1313e6 100644 --- a/activesupport/lib/active_support/test_case.rb +++ b/activesupport/lib/active_support/test_case.rb @@ -84,7 +84,7 @@ def test_order # writing your own implementation, you can set +parallelize_databases+, or configure it # via +config.active_support.parallelize_test_databases+. # - # parallelize(workers: :number_of_processes, parallelize_databases: false) + # parallelize(workers: :number_of_processors, parallelize_databases: false) # # Note that your test suite may deadlock if you attempt to use only one database # with multiple processes. From 228de01bd5e6c78c44c6b26943a9a636844b922b Mon Sep 17 00:00:00 2001 From: Eduardo Hernandez Date: Fri, 16 May 2025 12:14:41 -0600 Subject: [PATCH 0173/1075] docs: Adding example to assert_difference method Just found out that `assert_difference` supports passing a hash of expressions/numeric differences, so, I thought it'd be a good idea to include it as an example. Example: ```ruby assert_difference({ 'Article.count' => 1, 'Notification.count' => 2 }) do post :create, params: { article: {...} } end ``` --- .../lib/active_support/testing/assertions.rb | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/activesupport/lib/active_support/testing/assertions.rb b/activesupport/lib/active_support/testing/assertions.rb index 49139c45f1e2c..178b5b350abba 100644 --- a/activesupport/lib/active_support/testing/assertions.rb +++ b/activesupport/lib/active_support/testing/assertions.rb @@ -71,19 +71,19 @@ def assert_nothing_raised # post :delete, params: { id: ... } # end # - # An array of expressions can also be passed in and evaluated. + # An array of expressions can be passed in and evaluated. # # assert_difference [ 'Article.count', 'Post.count' ], 2 do # post :create, params: { article: {...} } # end # - # A hash of expressions/numeric differences can also be passed in and evaluated. + # A hash of expressions/numeric differences can be passed in and evaluated. # - # assert_difference ->{ Article.count } => 1, ->{ Notification.count } => 2 do + # assert_difference({ 'Article.count' => 1, 'Notification.count' => 2 }) do # post :create, params: { article: {...} } # end # - # A lambda or a list of lambdas can be passed in and evaluated: + # A lambda, a list of lambdas or a hash of lambdas/numeric differences can be passed in and evaluated: # # assert_difference ->{ Article.count }, 2 do # post :create, params: { article: {...} } @@ -93,6 +93,10 @@ def assert_nothing_raised # post :create, params: { article: {...} } # end # + # assert_difference ->{ Article.count } => 1, ->{ Notification.count } => 2 do + # post :create, params: { article: {...} } + # end + # # An error message can be specified. # # assert_difference 'Article.count', -1, 'An Article should be destroyed' do From c25c1c1c2175d170e1f7759e72b44bc981201601 Mon Sep 17 00:00:00 2001 From: Ryuta Kamizono Date: Sun, 18 May 2025 08:59:04 +0900 Subject: [PATCH 0174/1075] Prefer result.affected_rows over ivar --- .../sqlite3/database_statements.rb | 13 +++++++------ .../connection_adapters/sqlite3_adapter.rb | 1 - 2 files changed, 7 insertions(+), 7 deletions(-) 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 c550681bcf6d7..86f0aa8109d8b 100644 --- a/activerecord/lib/active_record/connection_adapters/sqlite3/database_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/sqlite3/database_statements.rb @@ -85,6 +85,7 @@ def internal_begin_transaction(mode, isolation) def perform_query(raw_connection, sql, binds, type_casted_binds, prepare:, notification_payload:, batch: false) total_changes_before_query = raw_connection.total_changes + affected_rows = nil if batch raw_connection.execute_batch2(sql) @@ -102,12 +103,12 @@ def perform_query(raw_connection, sql, binds, type_casted_binds, prepare:, notif end result = if stmt.column_count.zero? # No return stmt.step - @affected_rows = raw_connection.total_changes - total_changes_before_query - ActiveRecord::Result.empty(affected_rows: @affected_rows) + affected_rows = raw_connection.total_changes - total_changes_before_query + ActiveRecord::Result.empty(affected_rows: affected_rows) else rows = stmt.to_a - @affected_rows = raw_connection.total_changes - total_changes_before_query - ActiveRecord::Result.new(stmt.columns, rows, stmt.types.map { |t| type_map.lookup(t) }, affected_rows: @affected_rows) + affected_rows = raw_connection.total_changes - total_changes_before_query + ActiveRecord::Result.new(stmt.columns, rows, stmt.types.map { |t| type_map.lookup(t) }, affected_rows: affected_rows) end ensure stmt.close unless prepare @@ -115,7 +116,7 @@ def perform_query(raw_connection, sql, binds, type_casted_binds, prepare:, notif end verified! - notification_payload[:affected_rows] = @affected_rows + notification_payload[:affected_rows] = affected_rows notification_payload[:row_count] = result&.length || 0 result end @@ -127,7 +128,7 @@ def cast_result(result) end def affected_rows(result) - @affected_rows + result.affected_rows end def execute_batch(statements, name = nil, **kwargs) diff --git a/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb b/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb index aef0388e3f878..8e359ea466203 100644 --- a/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb @@ -149,7 +149,6 @@ def initialize(...) end end - @last_affected_rows = nil @previous_read_uncommitted = nil @config[:strict] = ConnectionAdapters::SQLite3Adapter.strict_strings_by_default unless @config.key?(:strict) From 9cb9b4cef9ad3d0ddc98ac0bbee380ad754d7ad7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A9=20Dupuis?= Date: Sun, 18 May 2025 15:10:01 -0700 Subject: [PATCH 0175/1075] Add support for multiple databases to `db:migrate:reset`. Fixes #55045 --- activerecord/CHANGELOG.md | 4 +++ .../lib/active_record/railties/databases.rake | 12 +++++++ .../test/application/rake/multi_dbs_test.rb | 32 +++++++++++++++++++ 3 files changed, 48 insertions(+) diff --git a/activerecord/CHANGELOG.md b/activerecord/CHANGELOG.md index 594321408d2c1..3382456dceb04 100644 --- a/activerecord/CHANGELOG.md +++ b/activerecord/CHANGELOG.md @@ -1,3 +1,7 @@ +* Add support for multiple databases to `db:migrate:reset`. + + *Joé Dupuis* + * Add `affected_rows` to `ActiveRecord::Result`. *Jenny Shen* diff --git a/activerecord/lib/active_record/railties/databases.rake b/activerecord/lib/active_record/railties/databases.rake index 3faa3052f63ba..95aeb9e82e35d 100644 --- a/activerecord/lib/active_record/railties/databases.rake +++ b/activerecord/lib/active_record/railties/databases.rake @@ -163,6 +163,18 @@ db_namespace = namespace :db do desc "Resets your database using your migrations for the current environment" task reset: ["db:drop", "db:create", "db:schema:dump", "db:migrate"] + namespace :reset do + ActiveRecord::Tasks::DatabaseTasks.for_each(databases) do |name| + desc "Drop and recreate the #{name} database using migrations" + task name => :load_config do + db_namespace["drop:#{name}"].invoke + db_namespace["create:#{name}"].invoke + db_namespace["schema:dump:#{name}"].invoke + db_namespace["migrate:#{name}"].invoke + end + end + end + desc 'Run the "up" for a given migration VERSION.' task up: :load_config do ActiveRecord::Tasks::DatabaseTasks.raise_for_multi_db(command: "db:migrate:up") diff --git a/railties/test/application/rake/multi_dbs_test.rb b/railties/test/application/rake/multi_dbs_test.rb index cea4e15bc1d5f..8ee2be8d5a703 100644 --- a/railties/test/application/rake/multi_dbs_test.rb +++ b/railties/test/application/rake/multi_dbs_test.rb @@ -993,6 +993,38 @@ class TwoMigration < ActiveRecord::Migration::Current end end + test "db:migrate:reset:primary regenerates the schema from migrations" do + require "#{app_path}/config/environment" + Dir.chdir(app_path) do + generate_models_for_animals + rails "db:migrate" + assert_not File.read("db/schema.rb").include?("director") + + + primary_mtime = File.mtime("db/schema.rb") + animals_mtime = File.mtime("db/animals_schema.rb") + + app_file "db/migrate/02_create_movies.rb", <<-MIGRATION + class CreateMovies < ActiveRecord::Migration::Current + create_table(:movies) { |t| t.string :director } + end + MIGRATION + + app_file "db/animals_migrate/02_new_animals.rb", <<-MIGRATION + class NewAnimals < ActiveRecord::Migration::Current + create_table(:cats) {} + end + MIGRATION + + rails "db:migrate:reset:primary" + + assert File.read("db/schema.rb").include?("director") + assert File.mtime("db/schema.rb") > primary_mtime + assert_equal animals_mtime, File.mtime("db/animals_schema.rb") + assert_not File.read("db/animals_schema.rb").include?("cats") + end + end + test "db:prepare works on all databases" do require "#{app_path}/config/environment" db_prepare From bf4cb692885171ffc00113a867bcf5ed5b4dce18 Mon Sep 17 00:00:00 2001 From: Aditya Pandit Date: Tue, 20 May 2025 09:03:19 +0530 Subject: [PATCH 0176/1075] Updated ruby version devcontainer --- .devcontainer/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 2c3d4d5497288..72eb8ea62754f 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,7 +1,7 @@ # See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.191.1/containers/ruby/.devcontainer/base.Dockerfile # [Choice] Ruby version: 3.4, 3.3, 3.2 -ARG VARIANT="3.4.3" +ARG VARIANT="3.4.4" FROM ghcr.io/rails/devcontainer/images/ruby:${VARIANT} RUN sudo apt-get update && export DEBIAN_FRONTEND=noninteractive \ From 8eb2c6f65fe19ec034e83cbe4149c0f366b50a85 Mon Sep 17 00:00:00 2001 From: Earlopain <14981592+Earlopain@users.noreply.github.com> Date: Tue, 20 May 2025 08:39:24 +0200 Subject: [PATCH 0177/1075] Avoid a module named `Namespace` for Ruby 3.5 This will be a feature in Ruby 3.5 and fails: > Namespace is not a module (TypeError) Many places in rails already use `Namespaced` for this purpose. Also reported upstream: https://bugs.ruby-lang.org/issues/21341 --- activemodel/test/cases/validations/validates_test.rb | 4 ++-- .../validators/{namespace => namespaced}/email_validator.rb | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) rename activemodel/test/validators/{namespace => namespaced}/email_validator.rb (87%) diff --git a/activemodel/test/cases/validations/validates_test.rb b/activemodel/test/cases/validations/validates_test.rb index 2ee3385814529..7281817bb10c5 100644 --- a/activemodel/test/cases/validations/validates_test.rb +++ b/activemodel/test/cases/validations/validates_test.rb @@ -4,7 +4,7 @@ require "models/person" require "models/topic" require "models/person_with_validator" -require "validators/namespace/email_validator" +require "validators/namespaced/email_validator" class ValidatesTest < ActiveModel::TestCase setup :reset_callbacks @@ -55,7 +55,7 @@ def test_validates_with_validator_class end def test_validates_with_namespaced_validator_class - Person.validates :karma, 'namespace/email': true + Person.validates :karma, 'namespaced/email': true person = Person.new person.valid? assert_equal ["is not an email"], person.errors[:karma] diff --git a/activemodel/test/validators/namespace/email_validator.rb b/activemodel/test/validators/namespaced/email_validator.rb similarity index 87% rename from activemodel/test/validators/namespace/email_validator.rb rename to activemodel/test/validators/namespaced/email_validator.rb index e7815d92dc415..f94b0e7b7fc08 100644 --- a/activemodel/test/validators/namespace/email_validator.rb +++ b/activemodel/test/validators/namespaced/email_validator.rb @@ -2,7 +2,7 @@ require "validators/email_validator" -module Namespace +module Namespaced class EmailValidator < ::EmailValidator end end From a4a1996dbe5f105257e891f16cee932b7a636455 Mon Sep 17 00:00:00 2001 From: Xavier Noria Date: Tue, 20 May 2025 13:40:21 +0200 Subject: [PATCH 0178/1075] :class_name should be invalid in polymorphic belongs_to --- .../lib/active_record/associations/builder/association.rb | 2 +- .../lib/active_record/associations/builder/belongs_to.rb | 5 +++-- .../associations/builder/collection_association.rb | 2 +- .../lib/active_record/associations/builder/has_one.rb | 2 +- .../test/cases/associations/has_many_associations_test.rb | 4 ++-- activerecord/test/models/cpk/comment.rb | 4 ++-- activerecord/test/models/sharded/blog_post.rb | 2 +- 7 files changed, 11 insertions(+), 10 deletions(-) diff --git a/activerecord/lib/active_record/associations/builder/association.rb b/activerecord/lib/active_record/associations/builder/association.rb index 0f80a2e81a10e..ea9c695f0a9dd 100644 --- a/activerecord/lib/active_record/associations/builder/association.rb +++ b/activerecord/lib/active_record/associations/builder/association.rb @@ -19,7 +19,7 @@ class << self self.extensions = [] VALID_OPTIONS = [ - :class_name, :anonymous_class, :primary_key, :foreign_key, :dependent, :validate, :inverse_of, :strict_loading, :query_constraints + :anonymous_class, :primary_key, :foreign_key, :dependent, :validate, :inverse_of, :strict_loading, :query_constraints ].freeze # :nodoc: def self.build(model, name, scope, options, &block) diff --git a/activerecord/lib/active_record/associations/builder/belongs_to.rb b/activerecord/lib/active_record/associations/builder/belongs_to.rb index aa5a7c3dace8c..2fbe39910d3df 100644 --- a/activerecord/lib/active_record/associations/builder/belongs_to.rb +++ b/activerecord/lib/active_record/associations/builder/belongs_to.rb @@ -8,8 +8,9 @@ def self.macro def self.valid_options(options) valid = super + [:polymorphic, :counter_cache, :optional, :default] - valid += [:foreign_type] if options[:polymorphic] - valid += [:ensuring_owner_was] if options[:dependent] == :destroy_async + valid << :class_name unless options[:polymorphic] + valid << :foreign_type if options[:polymorphic] + valid << :ensuring_owner_was if options[:dependent] == :destroy_async valid end diff --git a/activerecord/lib/active_record/associations/builder/collection_association.rb b/activerecord/lib/active_record/associations/builder/collection_association.rb index 391b2e4da3562..2133b4ec23282 100644 --- a/activerecord/lib/active_record/associations/builder/collection_association.rb +++ b/activerecord/lib/active_record/associations/builder/collection_association.rb @@ -7,7 +7,7 @@ class CollectionAssociation < Association # :nodoc: CALLBACKS = [:before_add, :after_add, :before_remove, :after_remove] def self.valid_options(options) - super + [:before_add, :after_add, :before_remove, :after_remove, :extend] + super + [:class_name, :before_add, :after_add, :before_remove, :after_remove, :extend] end def self.define_callbacks(model, reflection) diff --git a/activerecord/lib/active_record/associations/builder/has_one.rb b/activerecord/lib/active_record/associations/builder/has_one.rb index 73e0fa38ecc86..cf0bcbf1cc6b7 100644 --- a/activerecord/lib/active_record/associations/builder/has_one.rb +++ b/activerecord/lib/active_record/associations/builder/has_one.rb @@ -7,7 +7,7 @@ def self.macro end def self.valid_options(options) - valid = super + [:as, :through] + valid = super + [:class_name, :as, :through] valid += [:foreign_type] if options[:as] valid += [:ensuring_owner_was] if options[:dependent] == :destroy_async valid += [:source, :source_type, :disable_joins] if options[:through] diff --git a/activerecord/test/cases/associations/has_many_associations_test.rb b/activerecord/test/cases/associations/has_many_associations_test.rb index 424d32b9f9347..f32f408f6b3e4 100644 --- a/activerecord/test/cases/associations/has_many_associations_test.rb +++ b/activerecord/test/cases/associations/has_many_associations_test.rb @@ -3198,8 +3198,8 @@ def test_invalid_key_raises_with_message_including_all_default_options assert_equal(<<~MESSAGE.squish, error.message) Unknown key: :trough. Valid keys are: - :class_name, :anonymous_class, :primary_key, :foreign_key, :dependent, - :validate, :inverse_of, :strict_loading, :query_constraints, :autosave, :before_add, + :anonymous_class, :primary_key, :foreign_key, :dependent, :validate, :inverse_of, + :strict_loading, :query_constraints, :autosave, :class_name, :before_add, :after_add, :before_remove, :after_remove, :extend, :counter_cache, :join_table, :index_errors, :as, :through MESSAGE diff --git a/activerecord/test/models/cpk/comment.rb b/activerecord/test/models/cpk/comment.rb index 5b98e11218db7..117f0ed707bc9 100644 --- a/activerecord/test/models/cpk/comment.rb +++ b/activerecord/test/models/cpk/comment.rb @@ -3,7 +3,7 @@ module Cpk class Comment < ActiveRecord::Base self.table_name = :cpk_comments - belongs_to :commentable, class_name: "Cpk::Post", foreign_key: %i[commentable_title commentable_author], polymorphic: true - belongs_to :post, class_name: "Cpk::Post", foreign_key: %i[commentable_title commentable_author] + belongs_to :commentable, foreign_key: %i[commentable_title commentable_author], polymorphic: true + belongs_to :post, foreign_key: %i[commentable_title commentable_author] end end diff --git a/activerecord/test/models/sharded/blog_post.rb b/activerecord/test/models/sharded/blog_post.rb index b7a74867a3420..c5e2cb1d4d4ba 100644 --- a/activerecord/test/models/sharded/blog_post.rb +++ b/activerecord/test/models/sharded/blog_post.rb @@ -5,7 +5,7 @@ class BlogPost < ActiveRecord::Base self.table_name = :sharded_blog_posts query_constraints :blog_id, :id - belongs_to :parent, class_name: name, polymorphic: true + belongs_to :parent, polymorphic: true belongs_to :blog has_many :comments has_many :delete_comments, class_name: "Sharded::Comment", dependent: :delete_all From 9eb52bfbcdc58c56a5776c7c061adf86676fd87a Mon Sep 17 00:00:00 2001 From: Xavier Noria Date: Tue, 20 May 2025 16:14:22 +0200 Subject: [PATCH 0179/1075] Register a4a1996 in the CHANGELOG References #55089. --- activerecord/CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/activerecord/CHANGELOG.md b/activerecord/CHANGELOG.md index 3382456dceb04..98ba4f586f76e 100644 --- a/activerecord/CHANGELOG.md +++ b/activerecord/CHANGELOG.md @@ -1,3 +1,13 @@ +* `:class_name` is now invalid in polymorphic `belongs_to` associations. + + Reason is `:class_name` does not make sense in those associations because + the class name of target records is dynamic and stored in the type column. + + Existing polymorphic associations setting this option can just delete it. + While it did not raise, it had no effect anyway. + + *Xavier Noria* + * Add support for multiple databases to `db:migrate:reset`. *Joé Dupuis* From bfd3a3140d1ada1c3daede4aca911e5916db5c16 Mon Sep 17 00:00:00 2001 From: daffo Date: Tue, 20 May 2025 16:34:03 +0200 Subject: [PATCH 0180/1075] add explicit mention to select replacement in pluck API docs --- activerecord/lib/active_record/relation/calculations.rb | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/activerecord/lib/active_record/relation/calculations.rb b/activerecord/lib/active_record/relation/calculations.rb index 76bdd92e59330..24489d73e31ca 100644 --- a/activerecord/lib/active_record/relation/calculations.rb +++ b/activerecord/lib/active_record/relation/calculations.rb @@ -287,6 +287,11 @@ def calculate(operation, column_name) # # SELECT DATEDIFF(updated_at, created_at) FROM people # # => ['0', '27761', '173'] # + # Be aware that #pluck ignores any previous select clauses + # + # Person.select(:name).pluck(:id) + # # SELECT people.id FROM people + # # See also #ids. def pluck(*column_names) if @none From 46ae68fa0fbae1eab386be431b379e988bbbbdeb Mon Sep 17 00:00:00 2001 From: Joseph Hale Date: Tue, 20 May 2025 20:40:14 -0700 Subject: [PATCH 0181/1075] Include Action Text pins in Import Maps section For readers going through the Getting Started guide linearly, `config/importmap.rb` will contain two additional pins beyond those scaffolded within a new rails project. The additional pins come from the section "Rich Text Fields with Action Text", specifically the command `bin/rails action_text:install` --- guides/source/getting_started.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/guides/source/getting_started.md b/guides/source/getting_started.md index bd611145aa510..d58a079fec786 100644 --- a/guides/source/getting_started.md +++ b/guides/source/getting_started.md @@ -2533,6 +2533,8 @@ pin "@hotwired/turbo-rails", to: "turbo.min.js" pin "@hotwired/stimulus", to: "stimulus.min.js" pin "@hotwired/stimulus-loading", to: "stimulus-loading.js" pin_all_from "app/javascript/controllers", under: "controllers" +pin "trix" +pin "@rails/actiontext", to: "actiontext.esm.js" ``` TIP: Each pin maps a JavaScript package name (e.g., `"@hotwired/turbo-rails"`) From c696628d4cdc79f1606aa090b0fb1ca710a861a6 Mon Sep 17 00:00:00 2001 From: Joseph Hale Date: Tue, 20 May 2025 21:45:34 -0700 Subject: [PATCH 0182/1075] Prefer `.zero?` over `== 0` to match code extracted into a concern The code written in the "In Stock Email Notifications" section uses `.zero?` to check if the inventory was empty. In contrast the code snippet shown in the following section, "Extracting a Concern", changes the comparison to `== 0`. Consistency one way or the other would help reduce confusion. --- guides/source/getting_started.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/guides/source/getting_started.md b/guides/source/getting_started.md index bd611145aa510..232a91e872dad 100644 --- a/guides/source/getting_started.md +++ b/guides/source/getting_started.md @@ -2315,7 +2315,7 @@ module Product::Notifications end def back_in_stock? - inventory_count_previously_was == 0 && inventory_count > 0 + inventory_count_previously_was.zero? && inventory_count > 0 end def notify_subscribers From f6481c4fc20a8d8d88aa6ba06a6a5e24e9bfa755 Mon Sep 17 00:00:00 2001 From: bhumi1102 Date: Thu, 22 May 2025 02:29:40 -0500 Subject: [PATCH 0183/1075] [RF-DOCS] Rails Application Template Guide - merge with Rails Generators Guide [ci-skip] (#55020) The goal of this PR is to update the Rails Application Template Guide. In the process of updating this guide, it made sense to merge its content into the Generators Guide, which already had a section about Templates. The scope of this PR is the Rails Application Template sections (the rest of the existing Generators Guide will be updated later on). Co-authored-by: Ridhwana Co-authored-by: Petrik de Heus Co-authored-by: Ridhwana --- guides/source/documents.yaml | 8 - guides/source/generators.md | 369 ++++++++++++++++--- guides/source/rails_application_templates.md | 296 --------------- 3 files changed, 323 insertions(+), 350 deletions(-) delete mode 100644 guides/source/rails_application_templates.md diff --git a/guides/source/documents.yaml b/guides/source/documents.yaml index 5c36eff4b4403..395d453a92ee4 100644 --- a/guides/source/documents.yaml +++ b/guides/source/documents.yaml @@ -291,14 +291,6 @@ will learn how to create your own engine and integrate it with a host application. work_in_progress: true - - - name: Rails Application Templates - url: rails_application_templates.html - description: > - Application templates are simple Ruby files containing DSL for adding - gems, initializers, etc. to your freshly created Rails project or an - existing Rails project. - work_in_progress: true - name: Threading and Code Execution in Rails diff --git a/guides/source/generators.md b/guides/source/generators.md index d2e962d088619..b70c9611089d4 100644 --- a/guides/source/generators.md +++ b/guides/source/generators.md @@ -3,18 +3,15 @@ Creating and Customizing Rails Generators & Templates ===================================================== -Rails generators are an essential tool for improving your workflow. With this -guide you will learn how to create generators and customize existing ones. - -After reading this guide, you will know: +Rails generators and application templates are useful tools that can help improve your workflow by automatically creating boilerplate code. In this guide you will learn: * How to see which generators are available in your application. * How to create a generator using templates. * How Rails searches for generators before invoking them. -* How to customize your scaffold by overriding generator templates. -* How to customize your scaffold by overriding generators. +* How to customize Rails scaffolding by overriding generators and templates. * How to use fallbacks to avoid overwriting a huge set of generators. -* How to create an application template. +* How to use templates to create/customize Rails applications. +* How to use the Rails Template API to write your own reusable application templates. -------------------------------------------------------------------------------- @@ -452,13 +449,23 @@ $ bin/rails generate scaffold Comment body:text Application Templates --------------------- -Application templates are a special kind of generator. They can use all of the -[generator helper methods](#generator-helper-methods), but are written as a Ruby -script instead of a Ruby class. Here is an example: +Application templates are a little different from generators. While generators +add files to an existing Rails application (models, views, etc.), templates are +used to automate the setup of a new Rails application. Templates are Ruby +scripts (typically named `template.rb`) that customize new Rails applications +right after they are generated. + +Let's see how to use a template while creating a new Rails application. + +### Creating and Using Templates + +Let's start with a sample template Ruby script. The below template adds Devise +to the `Gemfile` after asking the user and also allows the user to name the +Devise user model. After `bundle install` has been run, the template runs the +Devise generators and also runs migrations. Finally, the template does `git add` and `git commit`. ```ruby # template.rb - if yes?("Would you like to install Devise?") gem "devise" devise_model = ask("What would you like the user model to be called?", default: "User") @@ -475,58 +482,316 @@ after_bundle do end ``` -First, the template asks the user whether they would like to install Devise. -If the user replies "yes" (or "y"), the template adds Devise to the `Gemfile`, -and asks the user for the name of the Devise user model (defaulting to `User`). -Later, after `bundle install` has been run, the template will run the Devise -generators and `bin/rails db:migrate` if a Devise model was specified. Finally, the -template will `git add` and `git commit` the entire app directory. - -We can run our template when generating a new Rails application by passing the -`-m` option to the `rails new` command: +To apply this template while creating a new Rails application, you need to +provide the location of the template using the `-m` option: ```bash -$ rails new my_cool_app -m path/to/template.rb +$ rails new blog -m ~/template.rb ``` -Alternatively, we can run our template inside an existing application with -`bin/rails app:template`: +The above will create a new Rails application called `blog` that has Devise gem configured. + +You can also apply templates to an existing Rails application by using +`app:template` command. The location of the template needs to be passed in via +the `LOCATION` environment variable: ```bash -$ bin/rails app:template LOCATION=path/to/template.rb +$ bin/rails app:template LOCATION=~/template.rb ``` -Templates also don't need to be stored locally — you can specify a URL instead +Templates don't have to be stored locally, you can also specify an URL instead of a path: ```bash -$ rails new my_cool_app -m http://example.com/template.rb -$ bin/rails app:template LOCATION=http://example.com/template.rb +$ rails new blog -m https://example.com/template.rb +$ bin/rails app:template LOCATION=https://example.com/template.rb +``` + +WARNING: Caution should be taken when executing remote scripts from third parties. Since the template is a plain Ruby script, it can easily contain code that compromises your local machine (such as download a virus, delete files or upload your private files to a server). + +The above `template.rb` file uses helper methods such as `after_bundle` and +`rails_command` and also adds user interactivity with methods like `yes?`. All +of these methods are part of the [Rails Template +API](https://edgeapi.rubyonrails.org/classes/Rails/Generators/Actions.html). The +following sections shows how to use more of these methods with examples. + +Rails Generators API +-------------------- + +Generators and the template Ruby scripts have access to several helper methods +using a [DSL](https://en.wikipedia.org/wiki/Domain-specific_language) (Domain +Specific Language). These methods are part of the Rails Generators API and you +can find more details at [`Thor::Actions`][] and +[`Rails::Generators::Actions`][] API documentation. + +Here's another example of a typical Rails template that scaffolds a model, runs +migrations, and commits the changes with git: + +```ruby +# template.rb +generate(:scaffold, "person name:string") +route "root to: 'people#index'" +rails_command("db:migrate") + +after_bundle do + git :init + git add: "." + git commit: %Q{ -m 'Initial commit' } +end +``` + +NOTE: All code snippets in the examples below can be used in a template +file, such as the `template.rb` file above. + +### add_source + +The [`add_source`][] method adds the given source to the generated application's `Gemfile`. + +```ruby +add_source "https://rubygems.org" +``` + +If a block is given, gem entries in the block are wrapped into the source group. +For example, if you need to source a gem from `"http://gems.github.com"`: + +```ruby +add_source "http://gems.github.com/" do + gem "rspec-rails" +end +``` + +### after_bundle + +The [`after_bundle`][] method registers a callback to be executed after the gems +are bundled. For example, it would make sense to run the "install" command for +`tailwindcss-rails` and `devise` only after those gems are bundled: + +```ruby +# Install gems +after_bundle do + # Install TailwindCSS + rails_command "tailwindcss:install" + + # Install Devise + generate "devise:install" +end +``` + +The callbacks get executed even if `--skip-bundle` has been passed. + +### environment + +The [`environment`][] method adds a line inside the `Application` class for +`config/application.rb`. If `options[:env]` is specified, the line is appended +to the corresponding file in `config/environments`. + +```ruby +environment 'config.action_mailer.default_url_options = {host: "http://yourwebsite.example.com"}', env: "production" +``` + +The above will add the config line to `config/environments/production.rb`. + +### gem + +The [`gem`][] helper adds an entry for the given gem to the generated application's +`Gemfile`. + +For example, if your application depends on the gems `devise` and +`tailwindcss-rails`: + +```ruby +gem "devise" +gem "tailwindcss-rails" +``` + +Note that this method only adds the gem to the `Gemfile`, it does not install +the gem. + +You can also specify an exact version: + +```ruby +gem "devise", "~> 4.9.4" +``` + +And you can also add comments that will be added to the `Gemfile`: + +```ruby +gem "devise", comment: "Add devise for authentication." +``` + +### gem_group + +The [`gem_group`][] helper wraps gem entries inside a group. For example, to load `rspec-rails` +only in the `development` and `test` groups: + +```ruby +gem_group :development, :test do + gem "rspec-rails" +end +``` + +### generate + +You can even call a generator from inside a `template.rb` with the +[`generate`][] method. The following runs the `scaffold` rails generator with +the given arguments: + +```ruby +generate(:scaffold, "person", "name:string", "address:text", "age:number") ``` -Generator Helper Methods ------------------------- +### git -Thor provides many generator helper methods via [`Thor::Actions`][], such as: +Rails templates let you run any git command with the [`git`][] helper: -* [`copy_file`][] -* [`create_file`][] -* [`gsub_file`][] -* [`insert_into_file`][] -* [`inside`][] +```ruby +git :init +git add: "." +git commit: "-a -m 'Initial commit'" +``` + +### initializer, vendor, lib, file + +The [`initializer`][] helper method adds an initializer to the generated +application's `config/initializers` directory. + +After adding the below to the `template.rb` file, you can use `Object#not_nil?` +and `Object#not_blank?` in your application: + +```ruby +initializer "not_methods.rb", <<-CODE + class Object + def not_nil? + !nil? + end + + def not_blank? + !blank? + end + end +CODE +``` + +Similarly, the [`lib`][] method creates a file in the `lib/` directory and +[`vendor`][] method creates a file in the `vendor/` directory. + +There is also a `file` method (which is an alias for [`create_file`][]), which +accepts a relative path from `Rails.root` and creates all the directories and +files needed: + +```ruby +file "app/components/foo.rb", <<-CODE + class Foo + end +CODE +``` + +The above will create the `app/components` directory and put `foo.rb` in there. + +### rakefile + +The [`rakefile`][] method creates a new Rake file under `lib/tasks` with the +given tasks: + +```ruby +rakefile("bootstrap.rake") do + <<-TASK + namespace :boot do + task :strap do + puts "I like boots!" + end + end + TASK +end +``` + +The above creates `lib/tasks/bootstrap.rake` with a `boot:strap` rake task. + +### run + +The [`run`][] method executes an arbitrary command. Let's say you want to remove +the `README.rdoc` file: + +```ruby +run "rm README.rdoc" +``` + +### rails_command + +You can run the Rails commands in the generated application with the +[`rails_command`][] helper. Let's say you want to migrate the database at some +point in the template ruby script: + +```ruby +rails_command "db:migrate" +``` + +Commands can be run with a different Rails environment: + +```ruby +rails_command "db:migrate", env: "production" +``` + +You can also run commands that should abort application generation if they fail: + +```ruby +rails_command "db:migrate", abort_on_failure: true +``` + +### route + +The [`route`][] method adds an entry to the `config/routes.rb` file. To make +`PeopleController#index` the default page for the application, we can add: + +```ruby +route "root to: 'person#index'" +``` + +There are also many helper methods that can manipulate the local file system, +such as [`copy_file`][], [`create_file`][], [`insert_into_file`][], and +[`inside`][]. You can see the [Thor API +documentation](https://www.rubydoc.info/gems/thor/Thor/Actions) for details. +Here is an example of one such method: + +### inside + +This [`inside`][] method enables you to run a command from a given directory. +For example, if you have a copy of edge rails that you wish to symlink from your +new apps, you can do this: -In addition to those, Rails also provides many helper methods via -[`Rails::Generators::Actions`][], such as: +```ruby +inside("vendor") do + run "ln -s ~/my-forks/rails rails" +end +``` + +There are also methods that allow you to interact with the user from the Ruby template, such as [`ask`][], [`yes`][], and [`no`][]. You can learn about all user interactivity methods in the [Thor Shell documentation](https://www.rubydoc.info/gems/thor/Thor/Shell/Basic). Let's see examples of using `ask`, `yes?` and `no?`: + +### ask + +The [`ask`][] methods allows you to get feedback from the user and use it in your +templates. Let's say you want your user to name the new shiny library you're +adding: + +```ruby +lib_name = ask("What do you want to call the shiny library?") +lib_name << ".rb" unless lib_name.index(".rb") + +lib lib_name, <<-CODE + class Shiny + end +CODE +``` + +### yes? or no? -* [`environment`][] -* [`gem`][] -* [`generate`][] -* [`git`][] -* [`initializer`][] -* [`lib`][] -* [`rails_command`][] -* [`rake`][] -* [`route`][] +These methods let you ask questions from templates and decide the flow based on +the user's answer. Let's say you want to prompt the user to run migrations: + +```ruby +rails_command("db:migrate") if yes?("Run database migrations?") +# no? questions acts the opposite of yes? +``` Testing Generators ------------------ @@ -562,3 +827,15 @@ In addition to those, Rails also provides additional assertions via [`Rails::Generators::Testing::Behaviour`]: https://api.rubyonrails.org/classes/Rails/Generators/Testing/Behavior.html [`run_generator`]: https://api.rubyonrails.org/classes/Rails/Generators/Testing/Behavior.html#method-i-run_generator [`Rails::Generators::Testing::Assertions`]: https://api.rubyonrails.org/classes/Rails/Generators/Testing/Assertions.html +[`add_source`]: https://api.rubyonrails.org/classes/Rails/Generators/Actions.html#method-i-add_source +[`after_bundle`]: https://api.rubyonrails.org/classes/Rails/Generators/AppGenerator.html#method-i-after_bundle +[`gem_group`]: https://api.rubyonrails.org/classes/Rails/Generators/Actions.html#method-i-gem_group +[`vendor`]: https://api.rubyonrails.org/classes/Rails/Generators/Actions.html#method-i-vendor +[`rakefile`]: https://api.rubyonrails.org/classes/Rails/Generators/Actions.html#method-i-rakefile +[`run`]: https://www.rubydoc.info/gems/thor/Thor/Actions#run-instance_method +[`copy_file`]: https://www.rubydoc.info/gems/thor/Thor/Actions#copy_file-instance_method +[`create_file`]: https://www.rubydoc.info/gems/thor/Thor/Actions#create_file-instance_method +[`ask`]: https://www.rubydoc.info/gems/thor/Thor/Shell/Basic#ask-instance_method +[`yes`]: https://www.rubydoc.info/gems/thor/Thor/Shell/Basic#yes%3F-instance_method +[`no`]: https://www.rubydoc.info/gems/thor/Thor/Shell/Basic#no%3F-instance_method + diff --git a/guides/source/rails_application_templates.md b/guides/source/rails_application_templates.md deleted file mode 100644 index 3399b29217bb2..0000000000000 --- a/guides/source/rails_application_templates.md +++ /dev/null @@ -1,296 +0,0 @@ -**DO NOT READ THIS FILE ON GITHUB, GUIDES ARE PUBLISHED ON .** - -Rails Application Templates -=========================== - -Application templates are simple Ruby files containing DSL for adding gems, initializers, etc. to your freshly created Rails project or an existing Rails project. - -After reading this guide, you will know: - -* How to use templates to generate/customize Rails applications. -* How to write your own reusable application templates using the Rails template API. - --------------------------------------------------------------------------------- - -Usage ------ - -To apply a template, you need to provide the Rails generator with the location of the template you wish to apply using the `-m` option. This can either be a path to a file or a URL. - -```bash -$ rails new blog -m ~/template.rb -$ rails new blog -m http://example.com/template.rb -``` - -You can use the `app:template` rails command to apply templates to an existing Rails application. The location of the template needs to be passed in via the LOCATION environment variable. Again, this can either be path to a file or a URL. - -```bash -$ bin/rails app:template LOCATION=~/template.rb -$ bin/rails app:template LOCATION=http://example.com/template.rb -``` - -Template API ------------- - -The Rails templates API is easy to understand. Here's an example of a typical Rails template: - -```ruby -# template.rb -generate(:scaffold, "person name:string") -route "root to: 'people#index'" -rails_command("db:migrate") - -after_bundle do - git :init - git add: "." - git commit: %Q{ -m 'Initial commit' } -end -``` - -The following sections outline the primary methods provided by the API: - -### gem(*args) - -Adds a `gem` entry for the supplied gem to the generated application's `Gemfile`. - -For example, if your application depends on the gems `bj` and `nokogiri`: - -```ruby -gem "bj" -gem "nokogiri" -``` - -Note that this method only adds the gem to the `Gemfile`; it does not install the gem. - -You can also specify an exact version: - -```ruby -gem "nokogiri", "~> 1.16.4" -``` - -And you can also add comments that will be added to the `Gemfile`: - -```ruby -gem "nokogiri", "~> 1.16.4", comment: "Add the nokogiri gem for XML parsing" -``` - -### gem_group(*names, &block) - -Wraps gem entries inside a group. - -For example, if you want to load `rspec-rails` only in the `development` and `test` groups: - -```ruby -gem_group :development, :test do - gem "rspec-rails" -end -``` - -### add_source(source, options={}, &block) - -Adds the given source to the generated application's `Gemfile`. - -For example, if you need to source a gem from `"http://gems.github.com"`: - -```ruby -add_source "http://gems.github.com" -``` - -If block is given, gem entries in block are wrapped into the source group. - -```ruby -add_source "http://gems.github.com/" do - gem "rspec-rails" -end -``` - -### environment/application(data=nil, options={}, &block) - -Adds a line inside the `Application` class for `config/application.rb`. - -If `options[:env]` is specified, the line is appended to the corresponding file in `config/environments`. - -```ruby -environment 'config.action_mailer.default_url_options = {host: "http://yourwebsite.example.com"}', env: "production" -``` - -A block can be used in place of the `data` argument. - -### vendor/lib/file/initializer(filename, data = nil, &block) - -Adds an initializer to the generated application's `config/initializers` directory. - -Let's say you like using `Object#not_nil?` and `Object#not_blank?`: - -```ruby -initializer "bloatlol.rb", <<-CODE - class Object - def not_nil? - !nil? - end - - def not_blank? - !blank? - end - end -CODE -``` - -Similarly, `lib()` creates a file in the `lib/` directory and `vendor()` creates a file in the `vendor/` directory. - -There is even `file()`, which accepts a relative path from `Rails.root` and creates all the directories/files needed: - -```ruby -file "app/components/foo.rb", <<-CODE - class Foo - end -CODE -``` - -That'll create the `app/components` directory and put `foo.rb` in there. - -### rakefile(filename, data = nil, &block) - -Creates a new rake file under `lib/tasks` with the supplied tasks: - -```ruby -rakefile("bootstrap.rake") do - <<-TASK - namespace :boot do - task :strap do - puts "i like boots!" - end - end - TASK -end -``` - -The above creates `lib/tasks/bootstrap.rake` with a `boot:strap` rake task. - -### generate(what, *args) - -Runs the supplied rails generator with given arguments. - -```ruby -generate(:scaffold, "person", "name:string", "address:text", "age:number") -``` - -### run(command) - -Executes an arbitrary command. Just like the backticks. Let's say you want to remove the `README.rdoc` file: - -```ruby -run "rm README.rdoc" -``` - -### rails_command(command, options = {}) - -Runs the supplied command in the Rails application. Let's say you want to migrate the database: - -```ruby -rails_command "db:migrate" -``` - -You can also run commands with a different Rails environment: - -```ruby -rails_command "db:migrate", env: "production" -``` - -You can also run commands as a super-user: - -```ruby -rails_command "log:clear", sudo: true -``` - -You can also run commands that should abort application generation if they fail: - -```ruby -rails_command "db:migrate", abort_on_failure: true -``` - -### route(routing_code) - -Adds a routing entry to the `config/routes.rb` file. In the steps above, we generated a person scaffold and also removed `README.rdoc`. Now, to make `PeopleController#index` the default page for the application: - -```ruby -route "root to: 'person#index'" -``` - -### inside(dir) - -Enables you to run a command from the given directory. For example, if you have a copy of edge rails that you wish to symlink from your new apps, you can do this: - -```ruby -inside("vendor") do - run "ln -s ~/commit-rails/rails rails" -end -``` - -### ask(question) - -`ask()` gives you a chance to get some feedback from the user and use it in your templates. Let's say you want your user to name the new shiny library you're adding: - -```ruby -lib_name = ask("What do you want to call the shiny library ?") -lib_name << ".rb" unless lib_name.index(".rb") - -lib lib_name, <<-CODE - class Shiny - end -CODE -``` - -### yes?(question) or no?(question) - -These methods let you ask questions from templates and decide the flow based on the user's answer. Let's say you want to prompt the user to run migrations: - -```ruby -rails_command("db:migrate") if yes?("Run database migrations?") -# no?(question) acts just the opposite. -``` - -### git(:command) - -Rails templates let you run any git command: - -```ruby -git :init -git add: "." -git commit: "-a -m 'Initial commit'" -``` - -### after_bundle(&block) - -Registers a callback to be executed after the gems are bundled and binstubs -are generated. Useful for adding generated files to version control: - -```ruby -after_bundle do - git :init - git add: "." - git commit: "-a -m 'Initial commit'" -end -``` - -The callbacks gets executed even if `--skip-bundle` has been passed. - -Advanced Usage --------------- - -The application template is evaluated in the context of a -`Rails::Generators::AppGenerator` instance. It uses the -[`apply`](https://www.rubydoc.info/gems/thor/Thor/Actions#apply-instance_method) -action provided by Thor. - -This means you can extend and change the instance to match your needs. - -For example by overwriting the `source_paths` method to contain the -location of your template. Now methods like `copy_file` will accept -relative paths to your template's location. - -```ruby -def source_paths - [__dir__] -end -``` From ad858b91a9a4bc94950708955e44c654a1f3789b Mon Sep 17 00:00:00 2001 From: Xavier Noria Date: Thu, 22 May 2025 10:05:05 +0200 Subject: [PATCH 0184/1075] Update docs re :class_name and polymorphic associations --- activerecord/lib/active_record/associations.rb | 4 +++- guides/source/association_basics.md | 3 +++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/activerecord/lib/active_record/associations.rb b/activerecord/lib/active_record/associations.rb index 2b3675fecf0fe..e57b7a5c789d8 100644 --- a/activerecord/lib/active_record/associations.rb +++ b/activerecord/lib/active_record/associations.rb @@ -1576,7 +1576,9 @@ def has_one(name, scope = nil, **options) # [+: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. + # if the real class name is Person, you'll have to specify it with this option. +:class_name+ + # is not supported in polymorphic associations, since in that case the class name of the + # associated record is stored in the type column. # [+: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 diff --git a/guides/source/association_basics.md b/guides/source/association_basics.md index daa0460eca034..7f27c1bb21c53 100644 --- a/guides/source/association_basics.md +++ b/guides/source/association_basics.md @@ -2579,6 +2579,9 @@ class Book < ApplicationRecord end ``` +This option is no supported in polymorphic associations, since in that case the +class name of the associated record is stored in the type column. + #### `:dependent` Controls what happens to the associated object when its owner is destroyed: From 85c1e56336f6882c078297c3d8bdfb1951360a53 Mon Sep 17 00:00:00 2001 From: Xavier Noria Date: Thu, 22 May 2025 17:06:51 +0200 Subject: [PATCH 0185/1075] Typo fix Thanks to @mjankowski for noticing. --- guides/source/association_basics.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/guides/source/association_basics.md b/guides/source/association_basics.md index 7f27c1bb21c53..7adc7cf7b61e9 100644 --- a/guides/source/association_basics.md +++ b/guides/source/association_basics.md @@ -2579,7 +2579,7 @@ class Book < ApplicationRecord end ``` -This option is no supported in polymorphic associations, since in that case the +This option is not supported in polymorphic associations, since in that case the class name of the associated record is stored in the type column. #### `:dependent` From 612666219b8c44770479c366d9efc936269033ee Mon Sep 17 00:00:00 2001 From: Xavier Noria Date: Thu, 22 May 2025 18:08:00 +0200 Subject: [PATCH 0186/1075] Restore :class_name in fixture This one is fine, thanks to @skipkayhil for pointing this one out. --- activerecord/test/models/cpk/comment.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/activerecord/test/models/cpk/comment.rb b/activerecord/test/models/cpk/comment.rb index 117f0ed707bc9..e9756a6db813f 100644 --- a/activerecord/test/models/cpk/comment.rb +++ b/activerecord/test/models/cpk/comment.rb @@ -4,6 +4,6 @@ module Cpk class Comment < ActiveRecord::Base self.table_name = :cpk_comments belongs_to :commentable, foreign_key: %i[commentable_title commentable_author], polymorphic: true - belongs_to :post, foreign_key: %i[commentable_title commentable_author] + belongs_to :post, class_name: "Cpk::Post", foreign_key: %i[commentable_title commentable_author] end end From 9767fa027925b6a8fe744cf9639a9019ca830064 Mon Sep 17 00:00:00 2001 From: Hartley McGuire Date: Thu, 22 May 2025 17:34:47 -0400 Subject: [PATCH 0187/1075] Bump libxml-ruby to compile with GCC 15 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trying to compile 5.0.3 errors: ``` $ gcc --version gcc (GCC) 15.1.1 20250425 $ bundle install Fetching gem metadata from https://rubygems.org/......... Installing libxml-ruby 5.0.3 with native extensions Gem::Ext::BuildError: ERROR: Failed to build gem native extension. current directory: /home/hartley/.local/share/mise/installs/ruby/3.4.4/lib/ruby/gems/3.4.0/gems/libxml-ruby-5.0.3/ext/libxml /home/hartley/.local/share/mise/installs/ruby/3.4.4/bin/ruby extconf.rb checking for libxml/xmlversion.h in /opt/include/libxml2,/opt/local/include/libxml2,/opt/homebrew/opt/libxml2/include/libxml2,/usr/local/include/libxml2,/usr/include/libxml2,/usr/local/include,/usr/local/opt/libxml2/include/libxml2... yes checking for xmlParseDoc() in -lxml2... yes creating extconf.h creating Makefile current directory: /home/hartley/.local/share/mise/installs/ruby/3.4.4/lib/ruby/gems/3.4.0/gems/libxml-ruby-5.0.3/ext/libxml make DESTDIR\= sitearchdir\=./.gem.20250522-279916-ldqtcq sitelibdir\=./.gem.20250522-279916-ldqtcq clean current directory: /home/hartley/.local/share/mise/installs/ruby/3.4.4/lib/ruby/gems/3.4.0/gems/libxml-ruby-5.0.3/ext/libxml make DESTDIR\= sitearchdir\=./.gem.20250522-279916-ldqtcq sitelibdir\=./.gem.20250522-279916-ldqtcq compiling libxml.c compiling ruby_xml.c compiling ruby_xml_attr.c ruby_xml_attr.c: In function ‘rxml_attr_wrap’: ruby_xml_attr.c:45:3: warning: ‘rb_data_object_wrap_warning’ is deprecated: by TypedData [-Wdeprecated-declarations] 45 | return Data_Wrap_Struct(cXMLAttr, rxml_attr_mark, NULL, xattr); | ^~~~~~ In file included from /home/hartley/.local/share/mise/installs/ruby/3.4.4/include/ruby-3.4.0/ruby/internal/core.h:27, from /home/hartley/.local/share/mise/installs/ruby/3.4.4/include/ruby-3.4.0/ruby/ruby.h:29, from /home/hartley/.local/share/mise/installs/ruby/3.4.4/include/ruby-3.4.0/ruby.h:38, from ruby_libxml.h:6, from ruby_xml_attr.c:30: /home/hartley/.local/share/mise/installs/ruby/3.4.4/include/ruby-3.4.0/ruby/internal/core/rdata.h:293:1: note: declared here 293 | rb_data_object_wrap_warning(VALUE klass, void *ptr, RUBY_DATA_FUNC mark, RUBY_DATA_FUNC free) | ^~~~~~~~~~~~~~~~~~~~~~~~~~~ ruby_xml_attr.c: In function ‘rxml_attr_alloc’: ruby_xml_attr.c:50:3: warning: ‘rb_data_object_wrap_warning’ is deprecated: by TypedData [-Wdeprecated-declarations] 50 | return Data_Wrap_Struct(klass, rxml_attr_mark, NULL, NULL); | ^~~~~~ /home/hartley/.local/share/mise/installs/ruby/3.4.4/include/ruby-3.4.0/ruby/internal/core/rdata.h:293:1: note: declared here 293 | rb_data_object_wrap_warning(VALUE klass, void *ptr, RUBY_DATA_FUNC mark, RUBY_DATA_FUNC free) | ^~~~~~~~~~~~~~~~~~~~~~~~~~~ ruby_xml_attr.c: In function ‘rxml_attr_initialize’: ruby_xml_attr.c:81:3: warning: ‘rb_data_object_get_warning’ is deprecated: by TypedData [-Wdeprecated-declarations] 81 | Data_Get_Struct(node, xmlNode, xnode); | ^~~~~~~~~~~~~~~ /home/hartley/.local/share/mise/installs/ruby/3.4.4/include/ruby-3.4.0/ruby/internal/core/rdata.h:325:1: note: declared here 325 | rb_data_object_get_warning(VALUE obj) | ^~~~~~~~~~~~~~~~~~~~~~~~~~ ruby_xml_attr.c:93:5: warning: ‘rb_data_object_get_warning’ is deprecated: by TypedData [-Wdeprecated-declarations] 93 | Data_Get_Struct(ns, xmlNs, xns); ... ``` --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 80db1f5673686..01d774f51bdba 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -346,7 +346,7 @@ GEM launchy (3.0.1) addressable (~> 2.8) childprocess (~> 5.0) - libxml-ruby (5.0.3) + libxml-ruby (5.0.4) lint_roller (1.1.0) listen (3.9.0) rb-fsevent (~> 0.10, >= 0.10.3) From 77eb0795fbb3ce91bdddd4a751347f606af2387c Mon Sep 17 00:00:00 2001 From: Xavier Noria Date: Fri, 23 May 2025 11:47:29 +0200 Subject: [PATCH 0188/1075] Document through + polymorphic --- activerecord/lib/active_record/associations.rb | 16 ++++++++++------ guides/source/association_basics.md | 8 +++++++- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/activerecord/lib/active_record/associations.rb b/activerecord/lib/active_record/associations.rb index e57b7a5c789d8..6684fcfe63ae9 100644 --- a/activerecord/lib/active_record/associations.rb +++ b/activerecord/lib/active_record/associations.rb @@ -1208,8 +1208,10 @@ module ClassMethods # [+:as+] # Specifies a polymorphic interface (See #belongs_to). # [+: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, + # Specifies an association through which to perform the query. + # + # This can be any other type of association, including other :through associations, + # but it cannot be a polymorphic association. Options for :class_name, # :primary_key and :foreign_key are ignored, as the association uses the # source reflection. # @@ -1411,10 +1413,12 @@ def has_many(name, scope = nil, **options, &extension) # [+:as+] # Specifies a polymorphic interface (See #belongs_to). # [+: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 - # or #belongs_to association on the join model. + # Specifies an association through which to perform the query. + # + # This can be any other type of association, including other :through associations, + # but it cannot be a polymorphic association. 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 or #belongs_to association on the join model. # # If the association on the join model is a #belongs_to, the collection can be modified # and the records on the :through model will be automatically created and removed diff --git a/guides/source/association_basics.md b/guides/source/association_basics.md index 7adc7cf7b61e9..c6a1767ea4a82 100644 --- a/guides/source/association_basics.md +++ b/guides/source/association_basics.md @@ -953,7 +953,7 @@ object, use the `collection.build` method. A [`has_many :through`][`has_many`] association is often used to set up a many-to-many relationship with another model. This association indicates that the declaring model can be matched with zero or more instances of another model -by proceeding _through_ a third model. +by proceeding _through_ an intermediate "join" model. For example, consider a medical practice where patients make appointments to see physicians. The relevant association declarations could look like this: @@ -1012,6 +1012,9 @@ In this migration the `physicians` and `patients` tables are created with a created with `physician_id` and `patient_id` columns, establishing the many-to-many relationship between `physicians` and `patients`. +INFO: The through association can be any type of association, including other +through associations, but it cannot be a polymorphic association. + You could also consider using a [composite primary key](active_record_composite_primary_keys.html) for the join table in the `has_many :through` relationship like below: @@ -1150,6 +1153,9 @@ class CreateAccountHistories < ActiveRecord::Migration[8.1] end ``` +INFO: The through association can be any type of association, including other +through associations, but it cannot be a polymorphic association. + ### `has_and_belongs_to_many` A [`has_and_belongs_to_many`][] association creates a direct many-to-many From cb073d417dea55778d696e8f74139d04be4e3de0 Mon Sep 17 00:00:00 2001 From: Edouard CHIN Date: Fri, 23 May 2025 00:35:31 +0200 Subject: [PATCH 0189/1075] Fix broken test when using rails-dom-testing 2.3: - Fix #55093 - The new version of dom testing now strip whitespaces when making a `text` assertion (See https://github.com/rails/rails-dom-testing/pull/123). Changing to an html assertion to preserve the previous behaviour and make the test pass on rails-dom-testing 2.3 --- Gemfile.lock | 2 +- actionpack/test/controller/caching_test.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 80db1f5673686..cae71adb2a81a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -469,7 +469,7 @@ GEM rack (>= 1.3) rackup (2.2.1) rack (>= 3) - rails-dom-testing (2.2.0) + rails-dom-testing (2.3.0) activesupport (>= 5.0.0) minitest nokogiri (>= 1.6) diff --git a/actionpack/test/controller/caching_test.rb b/actionpack/test/controller/caching_test.rb index eb2ab3451cab9..3540de0942211 100644 --- a/actionpack/test/controller/caching_test.rb +++ b/actionpack/test/controller/caching_test.rb @@ -414,7 +414,7 @@ def test_preserves_order_when_reading_from_cache_plus_rendering get :index_ordered assert_equal 3, @controller.partial_rendered_times - assert_select ":root", "david, 1\n david, 2\n david, 3" + assert_select ":root", html: "

david, 1\n david, 2\n david, 3\n\n

" end def test_explicit_render_call_with_options From 4c41a583d4856c73409f383eea9631538239da0e Mon Sep 17 00:00:00 2001 From: Xavier Noria Date: Sat, 24 May 2025 09:34:00 +0200 Subject: [PATCH 0190/1075] Improve docs through + polymorphic --- guides/source/association_basics.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/guides/source/association_basics.md b/guides/source/association_basics.md index c6a1767ea4a82..cc09b46d620d4 100644 --- a/guides/source/association_basics.md +++ b/guides/source/association_basics.md @@ -979,6 +979,9 @@ end allowing instances of one model (Physician) to be associated with multiple instances of another model (Patient) through a third "join" model (Appointment). +We call `Physician.appointments` and `Appointment.patient` the _through_ and +_source_ associations of `Physician.patients`, respectively. + ![has_many :through Association Diagram](images/association_basics/has_many_through.png) @@ -1013,7 +1016,8 @@ created with `physician_id` and `patient_id` columns, establishing the many-to-many relationship between `physicians` and `patients`. INFO: The through association can be any type of association, including other -through associations, but it cannot be a polymorphic association. +through associations, but it cannot be [polymorphic](#polymorphic-associations). +Source associations can be polymorphic as long as you provide a source type. You could also consider using a [composite primary key](active_record_composite_primary_keys.html) for the join table in the @@ -1125,6 +1129,9 @@ end This setup allows a `supplier` to directly access its `account_history` through its `account`. +We call `Supplier.account` and `Account.account_history` the _through_ and +_source_ associations of `Supplier.account_history`, respectively. + ![has_one :through Association Diagram](images/association_basics/has_one_through.png) @@ -1154,7 +1161,8 @@ end ``` INFO: The through association can be any type of association, including other -through associations, but it cannot be a polymorphic association. +through associations, but it cannot be [polymorphic](#polymorphic-associations). +Source associations can be polymorphic as long as you provide a source type. ### `has_and_belongs_to_many` From 3bca91915cf868564861dbf6bbf5a3d5367cfc77 Mon Sep 17 00:00:00 2001 From: Xavier Noria Date: Sat, 24 May 2025 17:48:05 +0200 Subject: [PATCH 0191/1075] Fix docs of has_one :through associations --- activerecord/lib/active_record/associations.rb | 4 ++-- guides/source/association_basics.md | 7 ++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/activerecord/lib/active_record/associations.rb b/activerecord/lib/active_record/associations.rb index 6684fcfe63ae9..b45fabdb6f32c 100644 --- a/activerecord/lib/active_record/associations.rb +++ b/activerecord/lib/active_record/associations.rb @@ -1415,8 +1415,8 @@ def has_many(name, scope = nil, **options, &extension) # [+:through+] # Specifies an association through which to perform the query. # - # This can be any other type of association, including other :through associations, - # but it cannot be a polymorphic association. Options for :class_name, :primary_key, + # The through association must be a `has_one`, `has_one :through`, or non-polymorphic `belongs_to`. + # That is, a non-polymorphic singular association. 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 or #belongs_to association on the join model. # diff --git a/guides/source/association_basics.md b/guides/source/association_basics.md index cc09b46d620d4..2017386d76717 100644 --- a/guides/source/association_basics.md +++ b/guides/source/association_basics.md @@ -1160,9 +1160,10 @@ class CreateAccountHistories < ActiveRecord::Migration[8.1] end ``` -INFO: The through association can be any type of association, including other -through associations, but it cannot be [polymorphic](#polymorphic-associations). -Source associations can be polymorphic as long as you provide a source type. +INFO: The through association must be a `has_one`, `has_one :through`, or +non-polymorphic `belongs_to`. That is, a non-polymorphic singular association. +On the other hand, source associations can be polymorphic as long as you provide +a source type. ### `has_and_belongs_to_many` From ca372893014050ace4c7f58eade51772ab810fa6 Mon Sep 17 00:00:00 2001 From: Xavier Noria Date: Sat, 24 May 2025 23:35:14 +0200 Subject: [PATCH 0192/1075] Fix RDoc markup --- activerecord/lib/active_record/associations.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/activerecord/lib/active_record/associations.rb b/activerecord/lib/active_record/associations.rb index b45fabdb6f32c..f48b9106d118b 100644 --- a/activerecord/lib/active_record/associations.rb +++ b/activerecord/lib/active_record/associations.rb @@ -1415,7 +1415,7 @@ def has_many(name, scope = nil, **options, &extension) # [+:through+] # Specifies an association through which to perform the query. # - # The through association must be a `has_one`, `has_one :through`, or non-polymorphic `belongs_to`. + # The through association must be a +has_one+, has_one :through, or non-polymorphic +belongs_to+. # That is, a non-polymorphic singular association. 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 or #belongs_to association on the join model. From 43925c3118500bfc3847b4db1e13280bcbfb5d6a Mon Sep 17 00:00:00 2001 From: Xavier Noria Date: Sat, 24 May 2025 23:40:22 +0200 Subject: [PATCH 0193/1075] Memoize successful reflection cache validation --- activerecord/lib/active_record/reflection.rb | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/activerecord/lib/active_record/reflection.rb b/activerecord/lib/active_record/reflection.rb index 1a6cb1bd2812b..79bd4c2702afa 100644 --- a/activerecord/lib/active_record/reflection.rb +++ b/activerecord/lib/active_record/reflection.rb @@ -520,6 +520,8 @@ def compute_class(name) def initialize(name, scope, options, active_record) super + + @validated = false @type = -(options[:foreign_type]&.to_s || "#{options[:as]}_type") if options[:as] @foreign_type = -(options[:foreign_type]&.to_s || "#{name}_type") if options[:polymorphic] @join_table = nil @@ -620,6 +622,8 @@ def join_foreign_key end def check_validity! + return if @validated + check_validity_of_inverse! if !polymorphic? && (klass.composite_primary_key? || active_record.composite_primary_key?) @@ -629,6 +633,8 @@ def check_validity! raise CompositePrimaryKeyMismatchError.new(self) end end + + @validated = true end def check_eager_loadable! @@ -979,6 +985,8 @@ class ThroughReflection < AbstractReflection # :nodoc: def initialize(delegate_reflection) super() + + @validated = false @delegate_reflection = delegate_reflection @klass = delegate_reflection.options[:anonymous_class] @source_reflection_name = delegate_reflection.options[:source] @@ -1142,6 +1150,8 @@ def through_options end def check_validity! + return if @validated + if through_reflection.nil? raise HasManyThroughAssociationNotFoundError.new(active_record, self) end @@ -1179,6 +1189,8 @@ def check_validity! end check_validity_of_inverse! + + @validated = true end def constraints From b46dba9a2cf6626d2f3b4601ec4370935209cb54 Mon Sep 17 00:00:00 2001 From: Xavier Noria Date: Sun, 25 May 2025 23:50:36 +0200 Subject: [PATCH 0194/1075] Internal docs for the query cache store --- .../connection_adapters/abstract/query_cache.rb | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/activerecord/lib/active_record/connection_adapters/abstract/query_cache.rb b/activerecord/lib/active_record/connection_adapters/abstract/query_cache.rb index f483829c8a348..8fa9a85fc977e 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/query_cache.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/query_cache.rb @@ -31,6 +31,18 @@ def #{method_name}(...) end end + # This is the actual query cache store. + # + # It has an internal hash whose keys are either SQL strings, or arrays of + # two elements [SQL string, binds], if there are binds. The hash values + # are their corresponding ActiveRecord::Result objects. + # + # Keeping the hash size under max size is achieved with LRU eviction. + # + # The store gets passed a version object, which is shared among the query + # cache stores of a given connection pool (see ConnectionPoolConfiguration + # down below). The version value may be externally changed as a way to + # signal cache invalidation, that is why all methods have a guard for it. class Store # :nodoc: attr_accessor :enabled, :dirties alias_method :enabled?, :enabled From 29857357cbe4ce32c02789efe1f0be5f5d4958aa Mon Sep 17 00:00:00 2001 From: TangRufus Date: Mon, 26 May 2025 02:26:19 +0100 Subject: [PATCH 0195/1075] Remove unnecessary `ruby-version` input from `ruby/setup-ruby` --- railties/CHANGELOG.md | 4 ++++ .../rails/generators/rails/app/templates/github/ci.yml.tt | 5 ----- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/railties/CHANGELOG.md b/railties/CHANGELOG.md index 91b5bddacb4c0..a8db37e567f78 100644 --- a/railties/CHANGELOG.md +++ b/railties/CHANGELOG.md @@ -1,3 +1,7 @@ +* Remove unnecessary `ruby-version` input from `ruby/setup-ruby` + + *TangRufus* + * Add --reset option to bin/setup which will call db:reset as part of the setup. *DHH* diff --git a/railties/lib/rails/generators/rails/app/templates/github/ci.yml.tt b/railties/lib/rails/generators/rails/app/templates/github/ci.yml.tt index 5cfd8306bfebb..285a67bedc146 100644 --- a/railties/lib/rails/generators/rails/app/templates/github/ci.yml.tt +++ b/railties/lib/rails/generators/rails/app/templates/github/ci.yml.tt @@ -17,7 +17,6 @@ jobs: - name: Set up Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: .ruby-version bundler-cache: true - name: Scan for common Rails security vulnerabilities using static analysis @@ -38,7 +37,6 @@ jobs: - name: Set up Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: .ruby-version bundler-cache: true - name: Scan for security vulnerabilities in JavaScript dependencies @@ -57,7 +55,6 @@ jobs: - name: Set up Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: .ruby-version bundler-cache: true - name: Prepare RuboCop cache @@ -125,7 +122,6 @@ jobs: - name: Set up Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: .ruby-version bundler-cache: true <%- if using_bun? -%> @@ -199,7 +195,6 @@ jobs: - name: Set up Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: .ruby-version bundler-cache: true <%- if using_bun? -%> From c19760f82d90d266f66ba6ecd34d2b762e5e678d Mon Sep 17 00:00:00 2001 From: Xavier Noria Date: Mon, 26 May 2025 08:31:55 +0200 Subject: [PATCH 0196/1075] Internal docs for the query cache registry --- .../connection_adapters/abstract/query_cache.rb | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/activerecord/lib/active_record/connection_adapters/abstract/query_cache.rb b/activerecord/lib/active_record/connection_adapters/abstract/query_cache.rb index 8fa9a85fc977e..a6773c91f675d 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/query_cache.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/query_cache.rb @@ -106,6 +106,12 @@ def check_version end end + # Each connection pool has one of these registries. They map execution + # contexts to query cache stores. + # + # The keys of the internal map are threads or fibers (whatever + # ActiveSupport::IsolatedExecutionState.context returns), and their + # associated values are their respective query cache stores. class QueryCacheRegistry # :nodoc: def initialize @mutex = Mutex.new From 40ecea70050af423bcae7e523625ad8c4eb7105b Mon Sep 17 00:00:00 2001 From: Andrey Sobolev Date: Thu, 29 May 2025 14:38:52 +0300 Subject: [PATCH 0197/1075] Fix typos in Action Mailer Basics guide --- guides/source/action_mailer_basics.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/guides/source/action_mailer_basics.md b/guides/source/action_mailer_basics.md index 88b6d86ad886c..203c52b2c1eee 100644 --- a/guides/source/action_mailer_basics.md +++ b/guides/source/action_mailer_basics.md @@ -156,12 +156,12 @@ Here is a sample HTML template that can be used for the welcome email: your username is: <%= @user.login %>.

- To login to the site, just follow this link: <%= link_to 'login', login_url %>. + To log in to the site, just follow this link: <%= link_to 'login', login_url %>.

Thanks for joining and have a great day!

``` -NOTE: the above is the content of the `` tag. It will be embedded in the +NOTE: The above is the content of the `` tag. It will be embedded in the default mailer layout, which contains the `` tag. See [Mailer layouts](#mailer-views-and-layouts) for more. @@ -178,7 +178,7 @@ Welcome to example.com, <%= @user.name %> You have successfully signed up to example.com, your username is: <%= @user.login %>. -To login to the site, just follow this link: <%= @url %>. +To log in to the site, just follow this link: <%= @url %>. Thanks for joining and have a great day! ``` @@ -187,7 +187,7 @@ Notice that in both HTML and text email templates you can use the instance variables `@user` and `@url`. Now, when you call the `mail` method, Action Mailer will detect the two -templates(text and HTML) and automatically generate a `multipart/alternative` +templates (text and HTML) and automatically generate a `multipart/alternative` email. ### Call the Mailer @@ -449,7 +449,7 @@ the mailer method. Mailer views are rendered within a layout, similar to controller views. Mailer layouts are located in `app/views/layouts`. The default layout is -`mailer.html.erb` and `mailer.text.erb`. This sections covers various features +`mailer.html.erb` and `mailer.text.erb`. This section covers various features around mailer views and layouts. ### Configuring Custom View Paths @@ -457,7 +457,7 @@ around mailer views and layouts. It is possible to change the default mailer view for your action in various ways, as shown below. -There is a `template_path` and `template_name` option to the `mail` method: +There are `template_path` and `template_name` options to the `mail` method: ```ruby class UserMailer < ApplicationMailer @@ -527,7 +527,7 @@ There is also an [`append_view_path`][] method. ### Generating URLs in Action Mailer Views -In order to add URLs to your mailer, you need set the `host` value to your +In order to add URLs to your mailer, you need to set the `host` value to your application's domain first. This is because, unlike controllers, the mailer instance doesn't have any context about the incoming request. @@ -1036,7 +1036,7 @@ Now the preview will be available at If you change something in the mailer view at `app/views/user_mailer/welcome_email.html.erb` or the mailer itself, the preview -will automatically be updated. A list of previews are also available in +will automatically be updated. A list of previews is also available in . By default, these preview classes live in `test/mailers/previews`. This can be From 0ac3fba6ee5f7d1680024a4925eee6069095487b Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Fri, 30 May 2025 08:35:32 +0100 Subject: [PATCH 0198/1075] Active Job Continuations (#55127) Continuations provide a mechanism for interrupting and resuming jobs. This allows long running jobs to make progress across application restarts. Jobs should include the `ActiveJob::Continuable` module to enable continuations. Continuable jobs are automatically retried when interrupted. Use the `step` method to define the steps in your job. Steps can use an optional cursor to track progress in the step. Steps are executed as soon as they are encountered. If a job is interrupted, previously completed steps will be skipped. If a step is in progress, it will be resumed with the last recorded cursor. Code that is not part of a step will be executed on each job execution. You can pass a block or a method name to the step method. The block will be called with the step object as an argument. Methods can either take no arguments or a single argument for the step object. ```ruby class ProcessImportJob < ApplicationJob include ActiveJob::Continuable def perform(import_id) # This always runs, even if the job is resumed. @import = Import.find(import_id) step :validate do @import.validate! end step :process_records do |step| @import.records.find_each(start: step.cursor) record.process step.advance! from: record.id end end step :reprocess_records step :finalize end def reprocess_records(step) @import.records.find_each(start: step.cursor) record.reprocess step.advance! from: record.id end end def finalize @import.finalize! end end ``` **Cursors** Cursors are used to track progress within a step. The cursor can be any object that is serializable as an argument to `ActiveJob::Base.serialize`. It defaults to `nil`. When a step is resumed, the last cursor value is restored. The code in the step is responsible for using the cursor to continue from the right point. `set!` sets the cursor to a specific value. ```ruby step :iterate_items do |step| items[step.cursor..].each do |item| process(item) step.set! (step.cursor || 0) + 1 end end ``` A starting value for the cursor can be set when defining the step: ```ruby step :iterate_items, start: 0 do |step| items[step.cursor..].each do |item| process(item) step.set! step.cursor + 1 end end ``` The cursor can be advanced with `advance!`. This calls `succ` on the current cursor value. It raises an `ActiveJob::Continuation::UnadvanceableCursorError` if the cursor does not implement `succ`. ```ruby step :iterate_items, start: 0 do |step| items[step.cursor..].each do |item| process(item) step.advance! end end ``` You can optionally pass a `from` argument to `advance!`. This is useful when iterating over a collection of records where IDs may not be contiguous. ```ruby step :process_records do |step| import.records.find_each(start: step.cursor) record.process step.advance! from: record.id end end ``` You can use an array to iterate over nested records: ```ruby step :process_nested_records, start: [ 0, 0 ] do |step| Account.find_each(start: step.cursor[0]) do |account| account.records.find_each(start: step.cursor[1]) do |record| record.process step.set! [ account.id, record.id + 1 ] end step.set! [ account.id + 1, 0 ] end end ``` Setting or advancing the cursor creates a checkpoint. You can also create a checkpoint manually by calling the `checkpoint!` method on the step. This is useful if you want to allow interruptions, but don't need to update the cursor. ```ruby step :destroy_records do |step| import.records.find_each do |record| record.destroy! step.checkpoint! end end ``` **Checkpoints** A checkpoint is where a job can be interrupted. At a checkpoint the job will call `queue_adapter.stopping?`. If it returns true, the job will raise an `ActiveJob::Continuation::Interrupt` exception. There is an automatic checkpoint at the end of each step. Within a step calling one is created when calling `set!`, `advance!` or `checkpoint!`. Jobs are not automatically interrupted when the queue adapter is marked as stopping - they will continue to run either until the next checkpoint, or when the process is stopped. This is to allow jobs to be interrupted at a safe point, but it also means that the jobs should checkpoint more frequently than the shutdown timeout to ensure a graceful restart. When interrupted, the job will automatically retry with the progress serialized in the job data under the `continuation` key. The serialized progress contains: - a list of the completed steps - the current step and its cursor value (if one is in progress) **Errors** If a job raises an error and is not retried via ActiveJob, it will be passed back to the queue adapter and any progress in this execution will be lost. To mitigate this, the job will automatically retried if it raises an error after it has made progress. Making progress is defined as having completed a step or advanced the cursor within the current step. **Queue Adapter support** Active Job Continuations call the `stopping?` method on the queue adapter to check if we are in the shutdown phase. By default this will return false, so the adapters will need to be updated to implement this method. This implements the `stopping?` method in the test and Sidekiq adapters. It would be possible to add support to Delayed Job via a plugin, but it would probably be better to add a new lifecycle callback to DJ for when it is shutting down. Resque also will require a new hook before it can be supported. Solid Queue's adapter is not part of Rails, but support can be added there via the `on_worker_stop` hook. **Inspiration** This took a lot inspiration from Shopify's [job-iteration](https://github.com/Shopify/job-iteration) gem. The main differences are: - Continuations are Active Job only, so they don't provide the custom enumerators that job-iteration does. - They allow multi-step flows - They don't intercept the perform method - Continuations are a sharp knife - you need to manually checkpoint and update the cursor. But you could build a job-iteration-like API on top of them. **Future work** It would be a good exercise to see if the job-iteration gem could be adapted to run on top of Active Job Continuations to highlight any missing features - we'd want to add things like max iteration time, max job runtime and forcing a job to stop. Another thing to consider is a mechanism for checking whether it is safe to call a checkpoint. Ideally you wouldn't allow them within database transactions as they'll cause a rollback. We can maybe inject checkpoint safety handlers and add a default one that checks whether we are in any active transactions. --- activejob/CHANGELOG.md | 39 ++ activejob/README.md | 4 + activejob/lib/active_job.rb | 1 + activejob/lib/active_job/continuable.rb | 59 +++ activejob/lib/active_job/continuation.rb | 321 ++++++++++++++++ activejob/lib/active_job/continuation/step.rb | 77 ++++ .../active_job/continuation/test_helper.rb | 87 +++++ activejob/lib/active_job/log_subscriber.rb | 56 +++ .../queue_adapters/abstract_adapter.rb | 6 + .../queue_adapters/sidekiq_adapter.rb | 12 + .../active_job/queue_adapters/test_adapter.rb | 6 +- activejob/test/cases/continuation_test.rb | 348 ++++++++++++++++++ activejob/test/cases/logging_test.rb | 33 +- activejob/test/cases/test_helper_test.rb | 21 +- activejob/test/integration/queuing_test.rb | 14 + .../test/jobs/continuable_array_cursor_job.rb | 16 + .../test/jobs/continuable_deleting_job.rb | 17 + .../jobs/continuable_duplicate_step_job.rb | 12 + .../test/jobs/continuable_iterating_job.rb | 28 ++ activejob/test/jobs/continuable_linear_job.rb | 31 ++ .../jobs/continuable_nested_cursor_job.rb | 21 ++ .../test/jobs/continuable_nested_steps_job.rb | 16 + .../jobs/continuable_resume_wrong_step_job.rb | 18 + .../jobs/continuable_string_step_name_job.rb | 10 + .../support/do_not_perform_enqueued_jobs.rb | 20 + .../support/integration/adapters/sidekiq.rb | 3 +- .../support/integration/dummy_app_template.rb | 33 ++ .../support/integration/test_case_helpers.rb | 4 + activejob/test/support/test_logger.rb | 34 ++ 29 files changed, 1294 insertions(+), 53 deletions(-) create mode 100644 activejob/lib/active_job/continuable.rb create mode 100644 activejob/lib/active_job/continuation.rb create mode 100644 activejob/lib/active_job/continuation/step.rb create mode 100644 activejob/lib/active_job/continuation/test_helper.rb create mode 100644 activejob/test/cases/continuation_test.rb create mode 100644 activejob/test/jobs/continuable_array_cursor_job.rb create mode 100644 activejob/test/jobs/continuable_deleting_job.rb create mode 100644 activejob/test/jobs/continuable_duplicate_step_job.rb create mode 100644 activejob/test/jobs/continuable_iterating_job.rb create mode 100644 activejob/test/jobs/continuable_linear_job.rb create mode 100644 activejob/test/jobs/continuable_nested_cursor_job.rb create mode 100644 activejob/test/jobs/continuable_nested_steps_job.rb create mode 100644 activejob/test/jobs/continuable_resume_wrong_step_job.rb create mode 100644 activejob/test/jobs/continuable_string_step_name_job.rb create mode 100644 activejob/test/support/do_not_perform_enqueued_jobs.rb create mode 100644 activejob/test/support/test_logger.rb diff --git a/activejob/CHANGELOG.md b/activejob/CHANGELOG.md index c1e3c8d15df11..a93fdcdb51396 100644 --- a/activejob/CHANGELOG.md +++ b/activejob/CHANGELOG.md @@ -1,3 +1,42 @@ +* Allow jobs to the interrupted and resumed with Continuations + + A job can use Continuations by including the `ActiveJob::Continuable` + concern. Continuations split jobs into steps. When the queuing system + is shutting down jobs can be interrupted and their progress saved. + + ```ruby + class ProcessImportJob + include ActiveJob::Continuable + + def perform(import_id) + @import = Import.find(import_id) + + # block format + step :initialize do + @import.initialize + end + + # step with cursor, the cursor is saved when the job is interrupted + step :process do |step| + @import.records.find_each(start: step.cursor) do |record| + record.process + step.advance! from: record.id + end + end + + # method format + step :finalize + + private + def finalize + @import.finalize + end + end + end + ``` + + *Donal McBreen* + * Defer invocation of ActiveJob enqueue callbacks until after commit when `enqueue_after_transaction_commit` is enabled. diff --git a/activejob/README.md b/activejob/README.md index eda7cc7292fae..1df9cc81bc3ae 100644 --- a/activejob/README.md +++ b/activejob/README.md @@ -95,6 +95,10 @@ their gem, or as a stand-alone gem. For discussion about this see the following PRs: [23311](https://github.com/rails/rails/issues/23311#issuecomment-176275718), [21406](https://github.com/rails/rails/pull/21406#issuecomment-138813484), and [#32285](https://github.com/rails/rails/pull/32285). +## Continuations + +Continuations allow jobs to be interrupted and resumed. See more at ActiveJob::Continuation. + ## Download and installation diff --git a/activejob/lib/active_job.rb b/activejob/lib/active_job.rb index fc6ff4ef8146d..a5db8ceab3ece 100644 --- a/activejob/lib/active_job.rb +++ b/activejob/lib/active_job.rb @@ -41,6 +41,7 @@ module ActiveJob autoload :SerializationError, "active_job/arguments" autoload :UnknownJobClassError, "active_job/core" autoload :EnqueueAfterTransactionCommit + autoload :Continuation eager_autoload do autoload :Serializers diff --git a/activejob/lib/active_job/continuable.rb b/activejob/lib/active_job/continuable.rb new file mode 100644 index 0000000000000..ea5e25684435f --- /dev/null +++ b/activejob/lib/active_job/continuable.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module ActiveJob + # = Active Job Continuable + # + # Mix ActiveJob::Continuable into your job to enable continuations. + # + # See +ActiveJob::Continuation+ for usage. # The Continuable module provides the ability to track the progress of your jobs, + # and continue from where they left off if interrupted. + # + module Continuable + extend ActiveSupport::Concern + + CONTINUATION_KEY = "continuation" + + included do + retry_on Continuation::Interrupt, attempts: :unlimited + retry_on Continuation::AfterAdvancingError, attempts: :unlimited + + around_perform :continue + end + + def step(step_name, start: nil, &block) + continuation.step(step_name, start: start) do |step| + if block_given? + block.call(step) + else + step_method = method(step_name) + + raise ArgumentError, "Step method '#{step_name}' must accept 0 or 1 arguments" if step_method.arity > 1 + + if step_method.parameters.any? { |type, name| type == :key || type == :keyreq } + raise ArgumentError, "Step method '#{step_name}' must not accept keyword arguments" + end + + step_method.arity == 0 ? step_method.call : step_method.call(step) + end + end + end + + def serialize + super.merge(CONTINUATION_KEY => continuation.to_h) + end + + def deserialize(job_data) + super + @continuation = Continuation.new(self, job_data.fetch(CONTINUATION_KEY, {})) + end + + private + def continuation + @continuation ||= Continuation.new(self, {}) + end + + def continue(&block) + continuation.continue(&block) + end + end +end diff --git a/activejob/lib/active_job/continuation.rb b/activejob/lib/active_job/continuation.rb new file mode 100644 index 0000000000000..7d235d73897d7 --- /dev/null +++ b/activejob/lib/active_job/continuation.rb @@ -0,0 +1,321 @@ +# frozen_string_literal: true + +require "active_support/core_ext/numeric/time" +require "active_job/continuable" + +module ActiveJob + # = Active Job \Continuation + # + # Continuations provide a mechanism for interrupting and resuming jobs. This allows + # long-running jobs to make progress across application restarts. + # + # Jobs should include the ActiveJob::Continuable module to enable continuations. + # \Continuable jobs are automatically retried when interrupted. + # + # Use the +step+ method to define the steps in your job. Steps can use an optional + # cursor to track progress in the step. + # + # Steps are executed as soon as they are encountered. If a job is interrupted, previously + # completed steps will be skipped. If a step is in progress, it will be resumed + # with the last recorded cursor. + # + # Code that is not part of a step will be executed on each job run. + # + # You can pass a block or a method name to the step method. The block will be called with + # the step object as an argument. Methods can either take no arguments or a single argument + # for the step object. + # + # class ProcessImportJob < ApplicationJob + # include ActiveJob::Continuable + # + # def perform(import_id) + # # This always runs, even if the job is resumed. + # @import = Import.find(import_id) + # + # step :validate do + # @import.validate! + # end + # + # step(:process_records) do |step| + # @import.records.find_each(start: step.cursor) + # record.process + # step.advance! from: record.id + # end + # end + # + # step :reprocess_records + # step :finalize + # end + # + # def reprocess_records(step) + # @import.records.find_each(start: step.cursor) + # record.reprocess + # step.advance! from: record.id + # end + # end + # + # def finalize + # @import.finalize! + # end + # end + # + # === Cursors + # + # Cursors are used to track progress within a step. The cursor can be any object that is + # serializable as an argument to +ActiveJob::Base.serialize+. It defaults to +nil+. + # + # When a step is resumed, the last cursor value is restored. The code in the step is responsible + # for using the cursor to continue from the right point. + # + # +set!+ sets the cursor to a specific value. + # + # step :iterate_items do |step| + # items[step.cursor..].each do |item| + # process(item) + # step.set! (step.cursor || 0) + 1 + # end + # end + # + # An starting value for the cursor can be set when defining the step: + # + # step :iterate_items, start: 0 do |step| + # items[step.cursor..].each do |item| + # process(item) + # step.set! step.cursor + 1 + # end + # end + # + # The cursor can be advanced with +advance!+. This calls +succ+ on the current cursor value. + # It raises an ActiveJob::Continuation::UnadvanceableCursorError if the cursor does not implement +succ+. + # + # step :iterate_items, start: 0 do |step| + # items[step.cursor..].each do |item| + # process(item) + # step.advance! + # end + # end + # + # You can optionally pass a +from+ argument to +advance!+. This is useful when iterating + # over a collection of records where IDs may not be contiguous. + # + # step :process_records do |step| + # import.records.find_each(start: step.cursor) + # record.process + # step.advance! from: record.id + # end + # end + # + # You can use an array to iterate over nested records: + # + # step :process_nested_records, start: [ 0, 0 ] do |step| + # Account.find_each(start: step.cursor[0]) do |account| + # account.records.find_each(start: step.cursor[1]) do |record| + # record.process + # step.set! [ account.id, record.id + 1 ] + # end + # step.set! [ account.id + 1, 0 ] + # end + # end + # + # Setting or advancing the cursor creates a checkpoint. You can also create a checkpoint + # manually by calling the +checkpoint!+ method on the step. This is useful if you want to + # allow interruptions, but don't need to update the cursor. + # + # step :destroy_records do |step| + # import.records.find_each do |record| + # record.destroy! + # step.checkpoint! + # end + # end + # + # === Checkpoints + # + # A checkpoint is where a job can be interrupted. At a checkpoint the job will call + # +queue_adapter.stopping?+. If it returns true, the job will raise an + # ActiveJob::Continuation::Interrupt exception. + # + # There is an automatic checkpoint at the end of each step. Within a step one is + # created when calling +set!+, +advance!+ or +checkpoint!+. + # + # Jobs are not automatically interrupted when the queue adapter is marked as stopping - they + # will continue to run either until the next checkpoint, or when the process is stopped. + # + # This is to allow jobs to be interrupted at a safe point, but it also means that the jobs + # should checkpoint more frequently than the shutdown timeout to ensure a graceful restart. + # + # When interrupted, the job will automatically retry with the progress serialized + # in the job data under the +continuation+ key. + # + # The serialized progress contains: + # - a list of the completed steps + # - the current step and its cursor value (if one is in progress) + # + # === Errors + # + # If a job raises an error and is not retried via Active Job, it will be passed back to the underlying + # queue backend and any progress in this execution will be lost. + # + # To mitigate this, the job will be automatically retried if it raises an error after it has made progress. + # Making progress is defined as having completed a step or advanced the cursor within the current step. + # + class Continuation + extend ActiveSupport::Autoload + + autoload :Step + + # Raised when a job is interrupted, allowing Active Job to requeue it. + # This inherits from +Exception+ rather than +StandardError+, so it's not + # caught by normal exception handling. + class Interrupt < Exception; end + + # Base error class for all Continuation errors. + class Error < StandardError; end + + # Raised when a step is invalid. + class InvalidStepError < Error; end + + # Raised when attempting to advance a cursor that doesn't implement `succ`. + class UnadvanceableCursorError < Error; end + + # Raised when an error occurs after a job has made progress. + # + # The job will be automatically retried to ensure that the progress is serialized + # in the retried job. + class AfterAdvancingError < Error; end + + def initialize(job, serialized_progress) + @job = job + @completed = serialized_progress.fetch("completed", []).map(&:to_sym) + @current = new_step(*serialized_progress["current"], resumed: true) if serialized_progress.key?("current") + @encountered_step_names = [] + @advanced = false + @running_step = false + end + + def continue(&block) + wrapping_errors_after_advancing do + instrument_job :resume if started? + block.call + end + end + + def step(name, start:, &block) + validate_step!(name) + + if completed?(name) + skip_step(name) + else + run_step(name, start: start, &block) + end + end + + def to_h + { + "completed" => completed.map(&:to_s), + "current" => current&.to_a + }.compact + end + + def description + if current + current.description + elsif completed.any? + "after '#{completed.last}'" + else + "not started" + end + end + + private + attr_reader :job, :encountered_step_names, :completed, :current + + def advanced? + @advanced + end + + def running_step? + @running_step + end + + def started? + completed.any? || current.present? + end + + def completed?(name) + completed.include?(name) + end + + def validate_step!(name) + raise InvalidStepError, "Step '#{name}' must be a Symbol, found '#{name.class}'" unless name.is_a?(Symbol) + raise InvalidStepError, "Step '#{name}' has already been encountered" if encountered_step_names.include?(name) + raise InvalidStepError, "Step '#{name}' is nested inside step '#{current.name}'" if running_step? + raise InvalidStepError, "Step '#{name}' found, expected to resume from '#{current.name}'" if current && current.name != name && !completed?(name) + + encountered_step_names << name + end + + def new_step(*args, **options) + Step.new(*args, **options) { checkpoint! } + end + + def skip_step(name) + instrument :step_skipped, step: name + end + + def run_step(name, start:, &block) + @running_step = true + @current ||= new_step(name, start, resumed: false) + + instrumenting_step(current) do + block.call(current) + end + + @completed << current.name + @current = nil + @advanced = true + + checkpoint! + ensure + @running_step = false + @advanced ||= current&.advanced? + end + + def interrupt! + instrument_job :interrupt + raise Interrupt, "Interrupted #{description}" + end + + def checkpoint! + interrupt! if job.queue_adapter.stopping? + end + + def wrapping_errors_after_advancing(&block) + block.call + rescue StandardError => e + if !e.is_a?(Error) && advanced? + raise AfterAdvancingError, "Advanced job failed with error: #{e.message}" + else + raise + end + end + + def instrumenting_step(step, &block) + instrument (step.resumed? ? :step_resumed : :step_started), step: step + + block.call + + instrument :step_completed, step: step + rescue Interrupt + instrument :step_interrupted, step: step + raise + end + + def instrument_job(event) + instrument event, description: description, completed_steps: completed, current_step: current + end + + def instrument(event, payload = {}) + job.send(:instrument, event, **payload) + end + end +end diff --git a/activejob/lib/active_job/continuation/step.rb b/activejob/lib/active_job/continuation/step.rb new file mode 100644 index 0000000000000..4bb2ca357cca8 --- /dev/null +++ b/activejob/lib/active_job/continuation/step.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +module ActiveJob + class Continuation + # = Active Job Continuation Step + # + # Represents a step within a continuable job. + # + # When a step is completed, it is recorded in the job's continuation state. + # If the job is interrupted, it will be resumed from after the last completed step. + # + # Steps also have an optional cursor that can be used to track progress within the step. + # If a job is interrupted during a step, the cursor will be saved and passed back when + # the job is resumed. + # + # It is the responsibility of the code in the step to use the cursor correctly to resume + # from where it left off. + class Step + # The name of the step. + attr_reader :name + + # The cursor for the step. + attr_reader :cursor + + def initialize(name, cursor, resumed:, &checkpoint_callback) + @name = name.to_sym + @initial_cursor = cursor + @cursor = cursor + @resumed = resumed + @checkpoint_callback = checkpoint_callback + end + + # Check if the job should be interrupted, and if so raise an Interrupt exception. + # The job will be requeued for retry. + def checkpoint! + checkpoint_callback.call + end + + # Set the cursor and interrupt the job if necessary. + def set!(cursor) + @cursor = cursor + checkpoint! + end + + # Advance the cursor from the current or supplied value + # + # The cursor will be advanced by calling the +succ+ method on the cursor. + # An UnadvanceableCursorError error will be raised if the cursor does not implement +succ+. + def advance!(from: nil) + from = cursor if from.nil? + raise UnadvanceableCursorError, "Cursor class '#{from.class}' does not implement succ, " unless from.respond_to?(:succ) + set! from.succ + end + + # Has this step been resumed from a previous job execution? + def resumed? + @resumed + end + + # Has the cursor been advanced during this job execution? + def advanced? + initial_cursor != cursor + end + + def to_a + [ name.to_s, cursor ] + end + + def description + "at '#{name}', cursor '#{cursor.inspect}'" + end + + private + attr_reader :checkpoint_callback, :initial_cursor + end + end +end diff --git a/activejob/lib/active_job/continuation/test_helper.rb b/activejob/lib/active_job/continuation/test_helper.rb new file mode 100644 index 0000000000000..ee9b0bdd2967d --- /dev/null +++ b/activejob/lib/active_job/continuation/test_helper.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +require "active_job/test_helper" +require "active_job/continuation" + +module ActiveJob + class Continuation + # Test helper for ActiveJob::Continuable jobs. + # + module TestHelper + include ::ActiveJob::TestHelper + + # Interrupt a job during a step. + # + # class MyJob < ApplicationJob + # include ActiveJob::Continuable + # + # cattr_accessor :items, default: [] + # def perform + # step :my_step, start: 1 do |step| + # (step.cursor..10).each do |i| + # items << i + # step.advance! + # end + # end + # end + # end + # + # test "interrupt job during step" do + # MyJob.perform_later + # interrupt_job_during_step(MyJob, :my_step, cursor: 6) { perform_enqueued_jobs } + # assert_equal [1, 2, 3, 4, 5], MyJob.items + # perform_enqueued_jobs + # assert_equal [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], MyJob.items + # end + def interrupt_job_during_step(job, step, cursor: nil, &block) + require_active_job_test_adapter!("interrupt_job_during_step") + queue_adapter.with(stopping: ->() { during_step?(job, step, cursor: cursor) }, &block) + end + + # Interrupt a job after a step. + # + # class MyJob < ApplicationJob + # include ActiveJob::Continuable + # + # cattr_accessor :items, default: [] + # + # def perform + # step :step_one { items << 1 } + # step :step_two { items << 2 } + # step :step_three { items << 3 } + # step :step_four { items << 4 } + # end + # end + # + # test "interrupt job after step" do + # MyJob.perform_later + # interrupt_job_after_step(MyJob, :step_two) { perform_enqueued_jobs } + # assert_equal [1, 2], MyJob.items + # perform_enqueued_jobs + # assert_equal [1, 2, 3, 4], MyJob.items + # end + def interrupt_job_after_step(job, step, &block) + require_active_job_test_adapter!("interrupt_job_after_step") + queue_adapter.with(stopping: ->() { after_step?(job, step) }, &block) + end + + private + def continuation_for(klass) + job = ActiveSupport::ExecutionContext.to_h[:job] + job.send(:continuation)&.to_h if job && job.is_a?(klass) + end + + def during_step?(job, step, cursor: nil) + if (continuation = continuation_for(job)) + continuation["current"] == [ step.to_s, cursor ] + end + end + + def after_step?(job, step) + if (continuation = continuation_for(job)) + continuation["completed"].last == step.to_s && continuation["current"].nil? + end + end + end + end +end diff --git a/activejob/lib/active_job/log_subscriber.rb b/activejob/lib/active_job/log_subscriber.rb index 5b156af0476c9..ce109af6df6ce 100644 --- a/activejob/lib/active_job/log_subscriber.rb +++ b/activejob/lib/active_job/log_subscriber.rb @@ -138,6 +138,62 @@ def discard(event) end subscribe_log_level :discard, :error + def interrupt(event) + job = event.payload[:job] + info do + "Interrupted #{job.class} (Job ID: #{job.job_id}) #{event.payload[:description]}" + end + end + subscribe_log_level :interrupt, :info + + def resume(event) + job = event.payload[:job] + info do + "Resuming #{job.class} (Job ID: #{job.job_id}) #{event.payload[:description]}" + end + end + subscribe_log_level :resume, :info + + def step_skipped(event) + job = event.payload[:job] + info do + "Step '#{event.payload[:step].name}' skipped #{job.class}" + end + end + subscribe_log_level :step_skipped, :info + + def step_started(event) + job = event.payload[:job] + info do + "Step '#{event.payload[:step].name}' started #{job.class}" + end + end + subscribe_log_level :step_started, :info + + def step_interrupted(event) + job = event.payload[:job] + info do + "Step '#{event.payload[:step].name}' interrupted at cursor '#{event.payload[:step].cursor}' #{job.class}" + end + end + subscribe_log_level :step_completed, :info + + def step_resumed(event) + job = event.payload[:job] + info do + "Step '#{event.payload[:step].name}' resumed from cursor '#{event.payload[:step].cursor}' #{job.class}" + end + end + subscribe_log_level :step_resumed, :info + + def step_completed(event) + job = event.payload[:job] + info do + "Step '#{event.payload[:step].name}' completed #{job.class}" + end + end + subscribe_log_level :step_completed, :info + private def queue_name(event) ActiveJob.adapter_name(event.payload[:adapter]) + "(#{event.payload[:job].queue_name})" diff --git a/activejob/lib/active_job/queue_adapters/abstract_adapter.rb b/activejob/lib/active_job/queue_adapters/abstract_adapter.rb index ed88bb5cd7596..5c1355d905925 100644 --- a/activejob/lib/active_job/queue_adapters/abstract_adapter.rb +++ b/activejob/lib/active_job/queue_adapters/abstract_adapter.rb @@ -7,6 +7,8 @@ module QueueAdapters # Active Job supports multiple job queue systems. ActiveJob::QueueAdapters::AbstractAdapter # forms the abstraction layer which makes this possible. class AbstractAdapter + attr_accessor :stopping + def enqueue(job) raise NotImplementedError end @@ -14,6 +16,10 @@ def enqueue(job) def enqueue_at(job, timestamp) raise NotImplementedError end + + def stopping? + !!@stopping + end end end end diff --git a/activejob/lib/active_job/queue_adapters/sidekiq_adapter.rb b/activejob/lib/active_job/queue_adapters/sidekiq_adapter.rb index b5084220f2002..2c1c7962dd80e 100644 --- a/activejob/lib/active_job/queue_adapters/sidekiq_adapter.rb +++ b/activejob/lib/active_job/queue_adapters/sidekiq_adapter.rb @@ -18,6 +18,18 @@ module QueueAdapters # # Rails.application.config.active_job.queue_adapter = :sidekiq class SidekiqAdapter < AbstractAdapter + def initialize(*) # :nodoc: + @stopping = false + + Sidekiq.configure_server do |config| + config.on(:quiet) { @stopping = true } + end + + Sidekiq.configure_client do |config| + config.on(:quiet) { @stopping = true } + end + end + def enqueue(job) # :nodoc: job.provider_job_id = JobWrapper.set( wrapped: job.class, diff --git a/activejob/lib/active_job/queue_adapters/test_adapter.rb b/activejob/lib/active_job/queue_adapters/test_adapter.rb index 8751260deaf9c..7869b2b1afce6 100644 --- a/activejob/lib/active_job/queue_adapters/test_adapter.rb +++ b/activejob/lib/active_job/queue_adapters/test_adapter.rb @@ -12,7 +12,7 @@ module QueueAdapters # # Rails.application.config.active_job.queue_adapter = :test class TestAdapter < AbstractAdapter - attr_accessor(:perform_enqueued_jobs, :perform_enqueued_at_jobs, :filter, :reject, :queue, :at) + attr_accessor(:perform_enqueued_jobs, :perform_enqueued_at_jobs, :filter, :reject, :queue, :at, :stopping) attr_writer(:enqueued_jobs, :performed_jobs) # Provides a store of all the enqueued jobs with the TestAdapter so you can check them. @@ -35,6 +35,10 @@ def enqueue_at(job, timestamp) # :nodoc: perform_or_enqueue(perform_enqueued_at_jobs && !filtered?(job), job, job_data) end + def stopping? + @stopping.is_a?(Proc) ? @stopping.call : @stopping + end + private def job_to_hash(job, extras = {}) job.serialize.tap do |job_data| diff --git a/activejob/test/cases/continuation_test.rb b/activejob/test/cases/continuation_test.rb new file mode 100644 index 0000000000000..dc1ae2a5bb93c --- /dev/null +++ b/activejob/test/cases/continuation_test.rb @@ -0,0 +1,348 @@ +# frozen_string_literal: true + +require "helper" +require "active_job/continuation/test_helper" +require "active_support/testing/stream" +require "active_support/core_ext/object/with" +require "support/test_logger" +require "support/do_not_perform_enqueued_jobs" +require "jobs/continuable_array_cursor_job" +require "jobs/continuable_iterating_job" +require "jobs/continuable_linear_job" +require "jobs/continuable_deleting_job" +require "jobs/continuable_duplicate_step_job" +require "jobs/continuable_nested_steps_job" +require "jobs/continuable_string_step_name_job" +require "jobs/continuable_resume_wrong_step_job" +require "jobs/continuable_nested_cursor_job" + +return unless adapter_is?(:test) + +class ActiveJob::TestContinuation < ActiveSupport::TestCase + include ActiveJob::Continuation::TestHelper + include ActiveSupport::Testing::Stream + include DoNotPerformEnqueuedJobs + include TestLoggerHelper + + test "iterates" do + ContinuableIteratingRecord.records = [ 123, 432, 6565, 3243, 234, 13, 22 ].map { |i| ContinuableIteratingRecord.new(i, "item_#{i}") } + + ContinuableIteratingJob.perform_later + + assert_enqueued_jobs 0, only: ContinuableIteratingJob do + perform_enqueued_jobs + end + + assert_equal %w[ new_item_123 new_item_432 new_item_6565 new_item_3243 new_item_234 new_item_13 new_item_22 ], ContinuableIteratingRecord.records.map(&:name) + end + + test "iterates and continues" do + ContinuableIteratingRecord.records = [ 123, 432, 6565, 3243, 234, 13, 22 ].map { |i| ContinuableIteratingRecord.new(i, "item_#{i}") } + + ContinuableIteratingJob.perform_later + + interrupt_job_during_step ContinuableIteratingJob, :rename, cursor: 433 do + assert_enqueued_jobs 1, only: ContinuableIteratingJob do + perform_enqueued_jobs + end + end + + assert_equal %w[ new_item_123 new_item_432 item_6565 item_3243 new_item_234 new_item_13 new_item_22 ], ContinuableIteratingRecord.records.map(&:name) + + assert_enqueued_jobs 0 do + perform_enqueued_jobs + end + + assert_equal %w[ new_item_123 new_item_432 new_item_6565 new_item_3243 new_item_234 new_item_13 new_item_22 ], ContinuableIteratingRecord.records.map(&:name) + end + + test "linear steps" do + ContinuableLinearJob.items = [] + ContinuableLinearJob.perform_later + + assert_enqueued_jobs 0 do + perform_enqueued_jobs + end + + assert_equal %w[ item1 item2 item3 item4 ], ContinuableLinearJob.items + end + + test "linear steps continues from last point" do + ContinuableLinearJob.items = [] + ContinuableLinearJob.perform_later + + interrupt_job_after_step ContinuableLinearJob, :step_one do + assert_enqueued_jobs 1, only: ContinuableLinearJob do + perform_enqueued_jobs + end + end + + assert_equal %w[ item1 ], ContinuableLinearJob.items + + assert_enqueued_jobs 0 do + perform_enqueued_jobs + end + + assert_equal %w[ item1 item2 item3 item4 ], ContinuableLinearJob.items + end + + test "runs with perform_now" do + ContinuableLinearJob.items = [] + ContinuableLinearJob.perform_now + + assert_equal %w[ item1 item2 item3 item4 ], ContinuableLinearJob.items + end + + test "does not retry jobs that error without updating the cursor" do + ContinuableDeletingJob.items = 10.times.map { |i| "item_#{i}" } + ContinuableDeletingJob.perform_later + + assert_enqueued_jobs 0, only: ContinuableDeletingJob do + assert_raises StandardError do + queue_adapter.with(stopping: ->() { raise StandardError if during_step?(ContinuableDeletingJob, :delete) }) do + perform_enqueued_jobs + end + end + end + + assert_equal %w[ item_1 item_2 item_3 item_4 item_5 item_6 item_7 item_8 item_9 ], ContinuableDeletingJob.items + end + + test "saves progress when there is an error" do + ContinuableIteratingRecord.records = [ 123, 432, 6565, 3243, 234, 13, 22 ].map { |i| ContinuableIteratingRecord.new(i, "item_#{i}") } + + ContinuableIteratingJob.perform_later + + queue_adapter.with(stopping: ->() { raise StandardError if during_step?(ContinuableIteratingJob, :rename, cursor: 433) }) do + assert_enqueued_jobs 1, only: ContinuableIteratingJob do + perform_enqueued_jobs + end + end + + job = queue_adapter.enqueued_jobs.first + assert_equal 1, job["executions"] + + assert_equal %w[ new_item_123 new_item_432 item_6565 item_3243 new_item_234 new_item_13 new_item_22 ], ContinuableIteratingRecord.records.map(&:name) + + assert_enqueued_jobs 0 do + perform_enqueued_jobs + end + + assert_equal %w[ new_item_123 new_item_432 new_item_6565 new_item_3243 new_item_234 new_item_13 new_item_22 ], ContinuableIteratingRecord.records.map(&:name) + end + + test "does not retry a second error if the cursor did not advance" do + ContinuableIteratingRecord.records = [ 123, 432, 6565, 3243, 234, 13, 22 ].map { |i| ContinuableIteratingRecord.new(i, "item_#{i}") } + + ContinuableIteratingJob.perform_later(raise_when_cursor: 433) + + assert_enqueued_jobs 1, only: ContinuableIteratingJob do + perform_enqueued_jobs + end + + job = queue_adapter.enqueued_jobs.first + assert_equal 1, job["executions"] + + assert_enqueued_jobs 0, only: ContinuableIteratingJob do + assert_raises StandardError do + perform_enqueued_jobs + end + end + end + + test "logs interruptions after steps" do + ContinuableLinearJob.items = [] + ContinuableLinearJob.perform_later + + interrupt_job_after_step ContinuableLinearJob, :step_one do + perform_enqueued_jobs + assert_no_match "Resuming", @logger.messages + assert_match(/Step 'step_one' started/, @logger.messages) + assert_match(/Step 'step_one' completed/, @logger.messages) + assert_match(/Interrupted ContinuableLinearJob \(Job ID: [0-9a-f-]{36}\) after 'step_one'/, @logger.messages) + end + + perform_enqueued_jobs + + assert_match(/Step 'step_one' skipped/, @logger.messages) + assert_match(/Resuming ContinuableLinearJob \(Job ID: [0-9a-f-]{36}\) after 'step_one'/, @logger.messages) + assert_match(/Step 'step_two' started/, @logger.messages) + assert_match(/Step 'step_two' completed/, @logger.messages) + end + + test "logs interruptions during steps" do + ContinuableIteratingRecord.records = [ 123, 432, 6565, 3243, 234, 13, 22 ].map { |i| ContinuableIteratingRecord.new(i, "item_#{i}") } + ContinuableIteratingJob.perform_later + + interrupt_job_during_step ContinuableIteratingJob, :rename, cursor: 433 do + perform_enqueued_jobs + assert_no_match "Resuming", @logger.messages + assert_match(/Step 'rename' started/, @logger.messages) + assert_match(/Step 'rename' interrupted at cursor '433'/, @logger.messages) + assert_match(/Interrupted ContinuableIteratingJob \(Job ID: [0-9a-f-]{36}\) at 'rename', cursor '433'/, @logger.messages) + end + + perform_enqueued_jobs + assert_match(/Resuming ContinuableIteratingJob \(Job ID: [0-9a-f-]{36}\) at 'rename', cursor '433'/, @logger.messages) + assert_match(/Step 'rename' resumed from cursor '433'/, @logger.messages) + assert_match(/Step 'rename' completed/, @logger.messages) + end + + test "interrupts without cursors" do + ContinuableDeletingJob.items = 10.times.map { |i| "item_#{i}" } + ContinuableDeletingJob.perform_later + + interrupt_job_during_step ContinuableDeletingJob, :delete do + assert_enqueued_jobs 1, only: ContinuableDeletingJob do + perform_enqueued_jobs + end + end + + assert_equal 9, ContinuableDeletingJob.items.count + + assert_enqueued_jobs 0 do + perform_enqueued_jobs + end + + assert_equal 0, ContinuableDeletingJob.items.count + end + + test "duplicate steps raise an error" do + ContinuableDuplicateStepJob.perform_later + + exception = assert_raises ActiveJob::Continuation::InvalidStepError do + perform_enqueued_jobs + end + + assert_equal "Step 'duplicate' has already been encountered", exception.message + end + + test "nested steps raise an error" do + ContinuableNestedStepsJob.perform_later + + exception = assert_raises ActiveJob::Continuation::InvalidStepError do + perform_enqueued_jobs + end + + assert_equal "Step 'inner_step' is nested inside step 'outer_step'", exception.message + end + + test "string named steps raise an error" do + ContinuableStringStepNameJob.perform_later + + exception = assert_raises ActiveJob::Continuation::InvalidStepError do + perform_enqueued_jobs + end + + assert_equal "Step 'string_step_name' must be a Symbol, found 'String'", exception.message + end + + test "unexpected step on resumption raises an error" do + ContinuableResumeWrongStepJob.perform_later + + interrupt_job_during_step ContinuableResumeWrongStepJob, :iterating, cursor: 2 do + perform_enqueued_jobs + end + + exception = assert_raises ActiveJob::Continuation::InvalidStepError do + perform_enqueued_jobs + end + + assert_equal "Step 'unexpected' found, expected to resume from 'iterating'", exception.message + end + + test "deserializes a job with no continuation" do + ContinuableDeletingJob.items = 10.times.map { |i| "item_#{i}" } + ContinuableDeletingJob.perform_later + + queue_adapter.enqueued_jobs.each { |job| job.delete("continuation") } + + assert_enqueued_jobs 0 do + perform_enqueued_jobs + end + + assert_equal 0, ContinuableDeletingJob.items.count + end + + test "nested cursor" do + ContinuableNestedCursorJob.items = [ + 3.times.map { |i| "subitem_0_#{i}" }, + 1.times.map { |i| "subitem_1_#{i}" }, + 2.times.map { |i| "subitem_2_#{i}" } + ] + ContinuableNestedCursorJob.perform_later + + assert_enqueued_jobs 0 do + perform_enqueued_jobs + end + + assert_equal [ %w[ new_subitem_0_0 new_subitem_0_1 new_subitem_0_2 ], %w[ new_subitem_1_0 ], %w[ new_subitem_2_0 new_subitem_2_1 ] ], ContinuableNestedCursorJob.items + end + + test "nested cursor resumes" do + ContinuableNestedCursorJob.items = [ + 3.times.map { |i| "subitem_0_#{i}" }, + 1.times.map { |i| "subitem_1_#{i}" }, + 2.times.map { |i| "subitem_2_#{i}" } + ] + + ContinuableNestedCursorJob.perform_later + + interrupt_job_during_step ContinuableNestedCursorJob, :updating_sub_items, cursor: [ 0, 2 ] do + assert_enqueued_jobs 1 do + perform_enqueued_jobs + end + end + + assert_equal [ %w[ new_subitem_0_0 new_subitem_0_1 subitem_0_2 ], %w[ subitem_1_0 ], %w[ subitem_2_0 subitem_2_1 ] ], ContinuableNestedCursorJob.items + + assert_enqueued_jobs 0 do + perform_enqueued_jobs + end + + assert_equal [ %w[ new_subitem_0_0 new_subitem_0_1 new_subitem_0_2 ], %w[ new_subitem_1_0 ], %w[ new_subitem_2_0 new_subitem_2_1 ] ], ContinuableNestedCursorJob.items + end + + test "iterates over array cursor" do + ContinuableArrayCursorJob.items = [] + + objects = [ :hello, "world", 1, 1.2, nil, true, false, [ 1, 2, 3 ], { a: 1, b: 2, c: 3 } ] + + ContinuableArrayCursorJob.perform_later(objects) + + assert_enqueued_jobs 0 do + perform_enqueued_jobs + end + + assert_equal objects, ContinuableArrayCursorJob.items + end + + test "interrupts and resumes array cursor" do + ContinuableArrayCursorJob.items = [] + + objects = [ :hello, "world", 1, 1.2, nil, true, false, [ 1, 2, 3 ], { a: 1, b: 2, c: 3 } ] + + ContinuableArrayCursorJob.perform_later(objects) + + assert_enqueued_jobs 1, only: ContinuableArrayCursorJob do + interrupt_job_during_step ContinuableArrayCursorJob, :iterate_objects, cursor: 3 do + perform_enqueued_jobs + end + end + + assert_equal objects[0...3], ContinuableArrayCursorJob.items + + assert_enqueued_jobs 0, only: ContinuableArrayCursorJob do + perform_enqueued_jobs + end + + assert_equal objects, ContinuableArrayCursorJob.items + end + + private + def capture_info_stdout(&block) + ActiveJob::Base.logger.with(level: :info) do + capture(:stdout, &block) + end + end +end diff --git a/activejob/test/cases/logging_test.rb b/activejob/test/cases/logging_test.rb index aa06edf8938e0..e7a2e102103b0 100644 --- a/activejob/test/cases/logging_test.rb +++ b/activejob/test/cases/logging_test.rb @@ -3,6 +3,7 @@ require "helper" require "active_support/log_subscriber/test_helper" require "active_support/core_ext/numeric/time" +require "support/test_logger" require "jobs/hello_job" require "jobs/logging_job" require "jobs/overridden_logging_job" @@ -18,37 +19,7 @@ class LoggingTest < ActiveSupport::TestCase include ActiveJob::TestHelper include ActiveSupport::LogSubscriber::TestHelper include ActiveSupport::Logger::Severity - - class TestLogger < ActiveSupport::Logger - def initialize - @file = StringIO.new - super(@file) - end - - def messages - @file.rewind - @file.read - end - end - - def setup - super - JobBuffer.clear - @old_logger = ActiveJob::Base.logger - @logger = ActiveSupport::TaggedLogging.new(TestLogger.new) - set_logger @logger - ActiveJob::LogSubscriber.attach_to :active_job - end - - def teardown - super - ActiveJob::LogSubscriber.log_subscribers.pop - set_logger @old_logger - end - - def set_logger(logger) - ActiveJob::Base.logger = logger - end + include TestLoggerHelper def test_uses_active_job_as_tag HelloJob.perform_later "Cristian" diff --git a/activejob/test/cases/test_helper_test.rb b/activejob/test/cases/test_helper_test.rb index 14ae83a196623..1ee24179e7ad9 100644 --- a/activejob/test/cases/test_helper_test.rb +++ b/activejob/test/cases/test_helper_test.rb @@ -14,26 +14,7 @@ require "jobs/inherited_job" require "jobs/multiple_kwargs_job" require "models/person" - -module DoNotPerformEnqueuedJobs - extend ActiveSupport::Concern - - included do - setup do - # /rails/activejob/test/adapters/test.rb sets these configs to true, but - # in this specific case we want to test enqueueing behaviour. - @perform_enqueued_jobs = queue_adapter.perform_enqueued_jobs - @perform_enqueued_at_jobs = queue_adapter.perform_enqueued_at_jobs - queue_adapter.perform_enqueued_jobs = queue_adapter.perform_enqueued_at_jobs = false - end - - teardown do - queue_adapter.perform_enqueued_jobs = @perform_enqueued_jobs - queue_adapter.perform_enqueued_at_jobs = @perform_enqueued_at_jobs - end - end -end - +require "support/do_not_perform_enqueued_jobs" class EnqueuedJobsTest < ActiveJob::TestCase if adapter_is?(:test) diff --git a/activejob/test/integration/queuing_test.rb b/activejob/test/integration/queuing_test.rb index 42f352533d682..3c2106006b03d 100644 --- a/activejob/test/integration/queuing_test.rb +++ b/activejob/test/integration/queuing_test.rb @@ -46,6 +46,20 @@ class QueuingTest < ActiveSupport::TestCase assert_equal "Provider Job ID: #{job.provider_job_id}", JobBuffer.last_value end end + + test "should interrupt jobs" do + ContinuableTestJob.perform_later @id + wait_for_jobs_to_finish_for(1.seconds) + + jobs_manager.stop_workers + wait_for_jobs_to_finish_for(1.seconds) + assert_not job_executed + assert continuable_job_started + + jobs_manager.start_workers + wait_for_jobs_to_finish_for(10.seconds) + assert job_executed + end end if adapter_is?(:delayed_job) diff --git a/activejob/test/jobs/continuable_array_cursor_job.rb b/activejob/test/jobs/continuable_array_cursor_job.rb new file mode 100644 index 0000000000000..80efab50a2cde --- /dev/null +++ b/activejob/test/jobs/continuable_array_cursor_job.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class ContinuableArrayCursorJob < ActiveJob::Base + include ActiveJob::Continuable + + cattr_accessor :items, default: [] + + def perform(objects) + step :iterate_objects, start: 0 do |step| + objects[step.cursor..].each do |object| + items << object + step.advance! + end + end + end +end diff --git a/activejob/test/jobs/continuable_deleting_job.rb b/activejob/test/jobs/continuable_deleting_job.rb new file mode 100644 index 0000000000000..40a2d6008dc6c --- /dev/null +++ b/activejob/test/jobs/continuable_deleting_job.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class ContinuableDeletingJob < ActiveJob::Base + include ActiveJob::Continuable + + cattr_accessor :items + + def perform + step :delete do |step| + loop do + break if items.empty? + items.shift + step.checkpoint! + end + end + end +end diff --git a/activejob/test/jobs/continuable_duplicate_step_job.rb b/activejob/test/jobs/continuable_duplicate_step_job.rb new file mode 100644 index 0000000000000..27ad13a2b3c1c --- /dev/null +++ b/activejob/test/jobs/continuable_duplicate_step_job.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class ContinuableDuplicateStepJob < ActiveJob::Base + include ActiveJob::Continuable + + def perform + step :duplicate do |step| + end + step :duplicate do |step| + end + end +end diff --git a/activejob/test/jobs/continuable_iterating_job.rb b/activejob/test/jobs/continuable_iterating_job.rb new file mode 100644 index 0000000000000..d6dc829ecf4c7 --- /dev/null +++ b/activejob/test/jobs/continuable_iterating_job.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +# Rough approximation of the AR batch iterator +ContinuableIteratingRecord = Struct.new(:id, :name) do + cattr_accessor :records + + def self.find_each(start: nil) + records.sort_by(&:id).each do |record| + next if start && record.id < start + + yield record + end + end +end + +class ContinuableIteratingJob < ActiveJob::Base + include ActiveJob::Continuable + + def perform(raise_when_cursor: nil) + step :rename do |step| + ContinuableIteratingRecord.find_each(start: step.cursor) do |record| + raise StandardError, "Cursor error" if raise_when_cursor && step.cursor == raise_when_cursor + record.name = "new_#{record.name}" + step.advance! from: record.id + end + end + end +end diff --git a/activejob/test/jobs/continuable_linear_job.rb b/activejob/test/jobs/continuable_linear_job.rb new file mode 100644 index 0000000000000..24f87ffa9f50d --- /dev/null +++ b/activejob/test/jobs/continuable_linear_job.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class ContinuableLinearJob < ActiveJob::Base + include ActiveJob::Continuable + + cattr_accessor :items + + def perform + step :step_one + step :step_two + step :step_three + step :step_four + end + + private + def step_one + items << "item1" + end + + def step_two + items << "item2" + end + + def step_three + items << "item3" + end + + def step_four + items << "item4" + end +end diff --git a/activejob/test/jobs/continuable_nested_cursor_job.rb b/activejob/test/jobs/continuable_nested_cursor_job.rb new file mode 100644 index 0000000000000..851af290781f0 --- /dev/null +++ b/activejob/test/jobs/continuable_nested_cursor_job.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class ContinuableNestedCursorJob < ActiveJob::Base + include ActiveJob::Continuable + + cattr_accessor :items + + def perform + step :updating_sub_items, start: [ 0, 0 ] do |step| + items[step.cursor[0]..].each do |inner_items| + inner_items[step.cursor[1]..].each do |item| + items[step.cursor[0]][step.cursor[1]] = "new_#{item}" + + step.set! [ step.cursor[0], step.cursor[1] + 1 ] + end + + step.set! [ step.cursor[0] + 1, 0 ] + end + end + end +end diff --git a/activejob/test/jobs/continuable_nested_steps_job.rb b/activejob/test/jobs/continuable_nested_steps_job.rb new file mode 100644 index 0000000000000..2e0952cc4c88d --- /dev/null +++ b/activejob/test/jobs/continuable_nested_steps_job.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class ContinuableNestedStepsJob < ActiveJob::Base + include ActiveJob::Continuable + + def perform + step :outer_step do + # Not allowed! + step :inner_step do + end + end + end + + private + def inner_step; end +end diff --git a/activejob/test/jobs/continuable_resume_wrong_step_job.rb b/activejob/test/jobs/continuable_resume_wrong_step_job.rb new file mode 100644 index 0000000000000..ec32fb79fed3b --- /dev/null +++ b/activejob/test/jobs/continuable_resume_wrong_step_job.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class ContinuableResumeWrongStepJob < ActiveJob::Base + include ActiveJob::Continuable + + def perform + if continuation.send(:started?) + step :unexpected do |step| + end + else + step :iterating, start: 0 do |step| + ((step.cursor || 1)..4).each do |i| + step.advance! + end + end + end + end +end diff --git a/activejob/test/jobs/continuable_string_step_name_job.rb b/activejob/test/jobs/continuable_string_step_name_job.rb new file mode 100644 index 0000000000000..b4ee0cdf8e267 --- /dev/null +++ b/activejob/test/jobs/continuable_string_step_name_job.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class ContinuableStringStepNameJob < ActiveJob::Base + include ActiveJob::Continuable + + def perform + step "string_step_name" do |step| + end + end +end diff --git a/activejob/test/support/do_not_perform_enqueued_jobs.rb b/activejob/test/support/do_not_perform_enqueued_jobs.rb new file mode 100644 index 0000000000000..3fded3d1cd196 --- /dev/null +++ b/activejob/test/support/do_not_perform_enqueued_jobs.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module DoNotPerformEnqueuedJobs + extend ActiveSupport::Concern + + included do + setup do + # /rails/activejob/test/adapters/test.rb sets these configs to true, but + # in this specific case we want to test enqueueing behaviour. + @perform_enqueued_jobs = queue_adapter.perform_enqueued_jobs + @perform_enqueued_at_jobs = queue_adapter.perform_enqueued_at_jobs + queue_adapter.perform_enqueued_jobs = queue_adapter.perform_enqueued_at_jobs = false + end + + teardown do + queue_adapter.perform_enqueued_jobs = @perform_enqueued_jobs + queue_adapter.perform_enqueued_at_jobs = @perform_enqueued_at_jobs + end + end +end diff --git a/activejob/test/support/integration/adapters/sidekiq.rb b/activejob/test/support/integration/adapters/sidekiq.rb index 023cadc8e7b8e..8a4c87da3bb49 100644 --- a/activejob/test/support/integration/adapters/sidekiq.rb +++ b/activejob/test/support/integration/adapters/sidekiq.rb @@ -58,7 +58,7 @@ def start_workers config = Sidekiq.default_configuration config.queues = ["integration_tests"] config.concurrency = 1 - config.average_scheduled_poll_interval = 0.5 + config.average_scheduled_poll_interval = 0.1 config.merge!( environment: "test", timeout: 1, @@ -107,6 +107,7 @@ def stop_workers Process.kill "TERM", @pid Process.wait @pid end + @pid = nil end def can_run? diff --git a/activejob/test/support/integration/dummy_app_template.rb b/activejob/test/support/integration/dummy_app_template.rb index 65229ba397c1b..a77b91c439c4b 100644 --- a/activejob/test/support/integration/dummy_app_template.rb +++ b/activejob/test/support/integration/dummy_app_template.rb @@ -29,3 +29,36 @@ def perform(x) end end CODE + +file "app/jobs/continuable_test_job.rb", <<-CODE +require "active_job/continuation" + +class ContinuableTestJob < ActiveJob::Base + include ActiveJob::Continuable + + queue_as :integration_tests + + def perform(x) + step :step_one do + raise "Rerunning step one!" if File.exist?(Rails.root.join("tmp/\#{x}.started")) + File.open(Rails.root.join("tmp/\#{x}.new"), "wb+") do |f| + f.write Marshal.dump({ + "locale" => I18n.locale.to_s || "en", + "timezone" => Time.zone&.name || "UTC", + "executed_at" => Time.now.to_r + }) + end + File.rename(Rails.root.join("tmp/\#{x}.new"), Rails.root.join("tmp/\#{x}.started")) + end + step :step_two do |step| + 8.times do |i| + sleep 0.25 + step.checkpoint! + end + end + step :step_three do + File.rename(Rails.root.join("tmp/\#{x}.started"), Rails.root.join("tmp/\#{x}")) + end + end +end +CODE diff --git a/activejob/test/support/integration/test_case_helpers.rb b/activejob/test/support/integration/test_case_helpers.rb index 69b8bc2672722..87a360cec6bc9 100644 --- a/activejob/test/support/integration/test_case_helpers.rb +++ b/activejob/test/support/integration/test_case_helpers.rb @@ -44,6 +44,10 @@ def job_executed(id = @id) job_file(id).exist? end + def continuable_job_started(id = @id) + job_file("#{id}.started").exist? + end + def job_data(id) Marshal.load(File.binread(job_file(id))) end diff --git a/activejob/test/support/test_logger.rb b/activejob/test/support/test_logger.rb new file mode 100644 index 0000000000000..9907c9252730f --- /dev/null +++ b/activejob/test/support/test_logger.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +class TestLogger < ActiveSupport::Logger + def initialize + @file = StringIO.new + super(@file) + end + + def messages + @file.rewind + @file.read + end +end + +module TestLoggerHelper + def setup + super + JobBuffer.clear + @old_logger = ActiveJob::Base.logger + @logger = ActiveSupport::TaggedLogging.new(TestLogger.new) + set_logger @logger + ActiveJob::LogSubscriber.attach_to :active_job + end + + def teardown + super + ActiveJob::LogSubscriber.log_subscribers.pop + set_logger @old_logger + end + + def set_logger(logger) + ActiveJob::Base.logger = logger + end +end From bd5fe5307af33e3375cd4438849bbe29299ce78d Mon Sep 17 00:00:00 2001 From: Tekin Suleyman Date: Thu, 15 May 2025 09:27:50 +0100 Subject: [PATCH 0199/1075] Correct ActiveStorage guide phrasing The example passes in the attachment to `url_for`, not the blob. Make it clear that either will work. --- guides/source/active_storage_overview.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/guides/source/active_storage_overview.md b/guides/source/active_storage_overview.md index b3f9d96a790b9..75ac041651235 100644 --- a/guides/source/active_storage_overview.md +++ b/guides/source/active_storage_overview.md @@ -693,8 +693,8 @@ require a higher level of protection consider implementing ### Redirect Mode -To generate a permanent URL for a blob, you can pass the blob to the -[`url_for`][ActionView::RoutingUrlFor#url_for] view helper. This generates a +To generate a permanent URL for a blob, you can pass the attachment or the blob to +the [`url_for`][ActionView::RoutingUrlFor#url_for] view helper. This generates a URL with the blob's [`signed_id`][ActiveStorage::Blob#signed_id] that is routed to the blob's [`RedirectController`][`ActiveStorage::Blobs::RedirectController`] From ec20b23b32f0d9972a8af457eeaf803a3a0f4053 Mon Sep 17 00:00:00 2001 From: Ross Wilson Date: Sun, 1 Jun 2025 15:06:35 +0100 Subject: [PATCH 0200/1075] Don't run unit tests within the System Tests GitHub Actions stage --- .../lib/rails/generators/rails/app/templates/github/ci.yml.tt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/railties/lib/rails/generators/rails/app/templates/github/ci.yml.tt b/railties/lib/rails/generators/rails/app/templates/github/ci.yml.tt index 285a67bedc146..4e5327abb4a38 100644 --- a/railties/lib/rails/generators/rails/app/templates/github/ci.yml.tt +++ b/railties/lib/rails/generators/rails/app/templates/github/ci.yml.tt @@ -215,7 +215,7 @@ jobs: <%- end -%> # RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }} # REDIS_URL: redis://localhost:6379/0 - run: bin/rails db:test:prepare test test:system + run: bin/rails db:test:prepare test:system - name: Keep screenshots from failed system tests uses: actions/upload-artifact@v4 From 475877fc5e2fffbb80c654c5248b5eb6a5cf587d Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Mon, 2 Jun 2025 13:14:54 +0200 Subject: [PATCH 0201/1075] Always fully clear current attributes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Attempting to reset instances is a recipe for causing state leak. Regular instance variables in `Current` subclasses aren't cleared, so unless you remember to undefine them in the `reset` callback they will leak across requests. We could of course document that, but it's just too much of a footgun, and I see no benefit of trying to re-use that object instance. It's much safer to just always drop it and start fresh. Closes: https://github.com/rails/rails/pull/55125/ Co-Authored-By: Janko Marohnić --- activesupport/CHANGELOG.md | 14 ++++++++++++++ .../lib/active_support/current_attributes.rb | 6 +----- .../current_attributes/test_helper.rb | 4 ++-- activesupport/lib/active_support/railtie.rb | 6 +++--- 4 files changed, 20 insertions(+), 10 deletions(-) diff --git a/activesupport/CHANGELOG.md b/activesupport/CHANGELOG.md index 19bc05993c8a4..279722ed12e5d 100644 --- a/activesupport/CHANGELOG.md +++ b/activesupport/CHANGELOG.md @@ -1,3 +1,17 @@ +* Always clear `CurrentAttribute` instances. + + Previously `CurrentAttribute` instance would be reset at the end of requests. + Meaning its attributes would be re-initialized. + + This is problematic because it assume these objects don't hold any state + other than their declared attribute, which isn't always the case, and + can lead to state leak across request. + + Now `CurrentAttribute` instances are abandonned at the end of a request, + and a new instance is created at the start of the next request. + + *Jean Boussier*, *Janko Marohnić* + * Add public API for `before_fork_hook` in parallel testing. Introduces a public API for calling the before fork hooks implemented by parallel testing. diff --git a/activesupport/lib/active_support/current_attributes.rb b/activesupport/lib/active_support/current_attributes.rb index d85562bd38aaa..76e2137386bc0 100644 --- a/activesupport/lib/active_support/current_attributes.rb +++ b/activesupport/lib/active_support/current_attributes.rb @@ -153,12 +153,8 @@ def resets(*methods, &block) delegate :set, :reset, to: :instance - def reset_all # :nodoc: - current_instances.each_value(&:reset) - end - def clear_all # :nodoc: - reset_all + current_instances.each_value(&:reset) current_instances.clear end diff --git a/activesupport/lib/active_support/current_attributes/test_helper.rb b/activesupport/lib/active_support/current_attributes/test_helper.rb index 2016384a80d89..681003240813c 100644 --- a/activesupport/lib/active_support/current_attributes/test_helper.rb +++ b/activesupport/lib/active_support/current_attributes/test_helper.rb @@ -2,12 +2,12 @@ module ActiveSupport::CurrentAttributes::TestHelper # :nodoc: def before_setup - ActiveSupport::CurrentAttributes.reset_all + ActiveSupport::CurrentAttributes.clear_all super end def after_teardown super - ActiveSupport::CurrentAttributes.reset_all + ActiveSupport::CurrentAttributes.clear_all end end diff --git a/activesupport/lib/active_support/railtie.rb b/activesupport/lib/active_support/railtie.rb index e5d89caf18ee2..33e4cd0be62f2 100644 --- a/activesupport/lib/active_support/railtie.rb +++ b/activesupport/lib/active_support/railtie.rb @@ -44,10 +44,10 @@ class Railtie < Rails::Railtie # :nodoc: app.executor.to_complete { ActiveSupport::ExecutionContext.clear } end - initializer "active_support.reset_all_current_attributes_instances" do |app| + initializer "active_support.clear_all_current_attributes_instances" do |app| app.reloader.before_class_unload { ActiveSupport::CurrentAttributes.clear_all } - app.executor.to_run { ActiveSupport::CurrentAttributes.reset_all } - app.executor.to_complete { ActiveSupport::CurrentAttributes.reset_all } + app.executor.to_run { ActiveSupport::CurrentAttributes.clear_all } + app.executor.to_complete { ActiveSupport::CurrentAttributes.clear_all } ActiveSupport.on_load(:active_support_test_case) do if app.config.active_support.executor_around_test_case From bfbfa4a118dc5ea9ba20b4767ae4ad39fe75c9c6 Mon Sep 17 00:00:00 2001 From: Edouard CHIN Date: Mon, 2 Jun 2025 15:22:02 +0200 Subject: [PATCH 0202/1075] Load tests prior to when Minitest load plugins: - Fix https://github.com/rails/rails/pull/54741#issuecomment-2922863186 - ### Problem Custom minitest plugins that require tests to be loaded (such as minitest-focus) may malfunction. This is due to a behaviour change introduced in #54741. ### Context Rails now load the test thanks to a custom plugin, but this creates a caveat: Any plugins running prior than ours may need the tests to be loaded. The order of thing in a normal minitest setup is: 1) Test files are loaded 2) Minitest load plugins 3) Minitest initialize plugins Before this patch it was: 1) Minitest load plugins 2) Minitest initialize plugins 3) Our plugin loads the test files. The plugins are loaded/initialized by Minitest in a random order. ### Solution I added a OptionsParser callback which get called at the very end of the parsing process and is responsible to load the test files. The order of thing is now: 1) Minitest load plugins 2) Test files are loaded 3) Minitest initialize the plugins --- railties/lib/minitest/rails_plugin.rb | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/railties/lib/minitest/rails_plugin.rb b/railties/lib/minitest/rails_plugin.rb index 7feebd515cd71..250dbfac8de3b 100644 --- a/railties/lib/minitest/rails_plugin.rb +++ b/railties/lib/minitest/rails_plugin.rb @@ -134,6 +134,12 @@ def self.plugin_rails_options(opts, options) options[:color] = true options[:output_inline] = true + + opts.on do + if ::Rails::TestUnit::Runner.load_test_files + ::Rails::TestUnit::Runner.load_tests(options.fetch(:test_files, [])) + end + end end # Owes great inspiration to test runner trailblazers like RSpec, @@ -142,10 +148,6 @@ def self.plugin_rails_init(options) # Don't mess with Minitest unless RAILS_ENV is set return unless ENV["RAILS_ENV"] || ENV["RAILS_MINITEST_PLUGIN"] - if ::Rails::TestUnit::Runner.load_test_files - ::Rails::TestUnit::Runner.load_tests(options.fetch(:test_files, [])) - end - unless options[:full_backtrace] # Plugin can run without Rails loaded, check before filtering. if ::Rails.respond_to?(:backtrace_cleaner) From d2d0ee9d20ead0744a7a14d23bdee3e2f56fe454 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20Barri=C3=A9?= Date: Mon, 2 Jun 2025 16:31:28 +0200 Subject: [PATCH 0203/1075] Keep the original job object when using retry_job When the job is retried, we want to preserve the original `scheduled_at`, `queue_name` and `priority`. Instead of using `dup`, this copies and restores the necessary attributes. --- activejob/lib/active_job/exceptions.rb | 6 ++++-- activejob/test/cases/exceptions_test.rb | 8 ++++++++ activejob/test/jobs/retries_job.rb | 26 +++++++++++++++++++++++++ 3 files changed, 38 insertions(+), 2 deletions(-) create mode 100644 activejob/test/jobs/retries_job.rb diff --git a/activejob/lib/active_job/exceptions.rb b/activejob/lib/active_job/exceptions.rb index 6433024683942..2966bca7b541d 100644 --- a/activejob/lib/active_job/exceptions.rb +++ b/activejob/lib/active_job/exceptions.rb @@ -157,8 +157,10 @@ def after_discard(&blk) # end def retry_job(options = {}) instrument :enqueue_retry, options.slice(:error, :wait) do - job = dup - job.enqueue options + scheduled_at, queue_name, priority = self.scheduled_at, self.queue_name, self.priority + enqueue options + ensure + self.scheduled_at, self.queue_name, self.priority = scheduled_at, queue_name, priority end end diff --git a/activejob/test/cases/exceptions_test.rb b/activejob/test/cases/exceptions_test.rb index 6e3fe5cc6ae9c..7e611795778cf 100644 --- a/activejob/test/cases/exceptions_test.rb +++ b/activejob/test/cases/exceptions_test.rb @@ -2,6 +2,7 @@ require "helper" require "jobs/retry_job" +require "jobs/retries_job" require "jobs/after_discard_retry_job" require "models/person" require "minitest/mock" @@ -343,6 +344,13 @@ def adapter_skips_scheduling?(queue_adapter) assert_equal ["Raised DefaultsError for the 5th time"], JobBuffer.values end + test "retrying a job when before_enqueue raised uses the same job object" do + job = RetriesJob.new + assert_nothing_raised do + job.enqueue + end + end + test "retrying a job reports error when report: true" do assert_error_reported(ReportedError) do RetryJob.perform_later("ReportedError", 2) diff --git a/activejob/test/jobs/retries_job.rb b/activejob/test/jobs/retries_job.rb new file mode 100644 index 0000000000000..c7f4020d56c1a --- /dev/null +++ b/activejob/test/jobs/retries_job.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +class BeforeEnqueueError < StandardError; end + +class RetriesJob < ActiveJob::Base + attr_accessor :raise_before_enqueue + + # The job fails in before_enqueue the first time it retries itself. + before_perform do + self.raise_before_enqueue = true + end + + # The job fails once to enqueue/retry itself, then succeeds. + before_enqueue do + raise BeforeEnqueueError if raise_before_enqueue + ensure + @raise_before_enqueue = false + end + + # The job retries on BeforeEnqueueError errors. + retry_on BeforeEnqueueError + + def perform + retry_job if executions <= 1 + end +end From 1eb7a9d5ef12bd5cf1c7229b866539dcd973f10c Mon Sep 17 00:00:00 2001 From: Joseph Izaguirre Date: Mon, 2 Jun 2025 10:47:11 -0700 Subject: [PATCH 0204/1075] Fix grammar --- guides/source/active_record_validations.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/guides/source/active_record_validations.md b/guides/source/active_record_validations.md index be5661b189336..8bd6c85984ac8 100644 --- a/guides/source/active_record_validations.md +++ b/guides/source/active_record_validations.md @@ -81,7 +81,7 @@ validations. There are two kinds of Active Record objects - those that correspond to a row inside your database and those that do not. When you instantiate a new object, using the `new` method, the object does not get saved in the database as yet. -Once you call `save` on that object then will it be saved into the appropriate +Once you call `save` on that object, it will be saved into the appropriate database table. Active Record uses an instance method called `persisted?` (and its inverse `new_record?`) to determine whether an object is already in the database or not. Consider the following Active Record class: From 656529436dad7dabd30ed730bb4c4126655b0fcd Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Tue, 3 Jun 2025 12:17:57 +0200 Subject: [PATCH 0205/1075] Get rid of an useless frame in AJ::Continuable#step --- activejob/lib/active_job/continuable.rb | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/activejob/lib/active_job/continuable.rb b/activejob/lib/active_job/continuable.rb index ea5e25684435f..6cdbd923e1d88 100644 --- a/activejob/lib/active_job/continuable.rb +++ b/activejob/lib/active_job/continuable.rb @@ -21,21 +21,18 @@ module Continuable end def step(step_name, start: nil, &block) - continuation.step(step_name, start: start) do |step| - if block_given? - block.call(step) - else - step_method = method(step_name) + unless block_given? + step_method = method(step_name) - raise ArgumentError, "Step method '#{step_name}' must accept 0 or 1 arguments" if step_method.arity > 1 + raise ArgumentError, "Step method '#{step_name}' must accept 0 or 1 arguments" if step_method.arity > 1 - if step_method.parameters.any? { |type, name| type == :key || type == :keyreq } - raise ArgumentError, "Step method '#{step_name}' must not accept keyword arguments" - end - - step_method.arity == 0 ? step_method.call : step_method.call(step) + if step_method.parameters.any? { |type, name| type == :key || type == :keyreq } + raise ArgumentError, "Step method '#{step_name}' must not accept keyword arguments" end + + block = step_method.arity == 0 ? -> (_) { step_method.call } : step_method end + continuation.step(step_name, start: start, &block) end def serialize From 8cdb1f58e5f91aeeb4f8f4f2757b83bf3b8b6162 Mon Sep 17 00:00:00 2001 From: willnet Date: Wed, 4 Jun 2025 12:59:41 +0900 Subject: [PATCH 0206/1075] [ci skip]Add missing `do |record|` to Active Job Continuation example --- activejob/lib/active_job/continuation.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/activejob/lib/active_job/continuation.rb b/activejob/lib/active_job/continuation.rb index 7d235d73897d7..0e02740bd30ac 100644 --- a/activejob/lib/active_job/continuation.rb +++ b/activejob/lib/active_job/continuation.rb @@ -37,7 +37,7 @@ module ActiveJob # end # # step(:process_records) do |step| - # @import.records.find_each(start: step.cursor) + # @import.records.find_each(start: step.cursor) do |record| # record.process # step.advance! from: record.id # end @@ -48,7 +48,7 @@ module ActiveJob # end # # def reprocess_records(step) - # @import.records.find_each(start: step.cursor) + # @import.records.find_each(start: step.cursor) do |record| # record.reprocess # step.advance! from: record.id # end @@ -99,7 +99,7 @@ module ActiveJob # over a collection of records where IDs may not be contiguous. # # step :process_records do |step| - # import.records.find_each(start: step.cursor) + # import.records.find_each(start: step.cursor) do |record| # record.process # step.advance! from: record.id # end From 50f2341b9871592a2546f469f05567df5ff9a723 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Tue, 3 Jun 2025 09:59:01 +0100 Subject: [PATCH 0207/1075] Make job.instrument public with nodoc --- activejob/lib/active_job/continuation.rb | 2 +- activejob/lib/active_job/instrumentation.rb | 24 +++++++++---------- .../lib/active_record/railties/job_runtime.rb | 21 ++++++++-------- 3 files changed, 23 insertions(+), 24 deletions(-) diff --git a/activejob/lib/active_job/continuation.rb b/activejob/lib/active_job/continuation.rb index 7d235d73897d7..f3672da289075 100644 --- a/activejob/lib/active_job/continuation.rb +++ b/activejob/lib/active_job/continuation.rb @@ -315,7 +315,7 @@ def instrument_job(event) end def instrument(event, payload = {}) - job.send(:instrument, event, **payload) + job.instrument event, **payload end end end diff --git a/activejob/lib/active_job/instrumentation.rb b/activejob/lib/active_job/instrumentation.rb index 8e84f8f250a51..4364ae8bedabe 100644 --- a/activejob/lib/active_job/instrumentation.rb +++ b/activejob/lib/active_job/instrumentation.rb @@ -26,24 +26,24 @@ def perform_now instrument(:perform) { super } end + def instrument(operation, payload = {}, &block) # :nodoc: + payload[:job] = self + payload[:adapter] = queue_adapter + + ActiveSupport::Notifications.instrument("#{operation}.active_job", payload) do + value = block.call if block + payload[:aborted] = @_halted_callback_hook_called if defined?(@_halted_callback_hook_called) + @_halted_callback_hook_called = nil + value + end + end + private def _perform_job instrument(:perform_start) super end - def instrument(operation, payload = {}, &block) - payload[:job] = self - payload[:adapter] = queue_adapter - - ActiveSupport::Notifications.instrument("#{operation}.active_job", payload) do - value = block.call if block - payload[:aborted] = @_halted_callback_hook_called if defined?(@_halted_callback_hook_called) - @_halted_callback_hook_called = nil - value - end - end - def halted_callback_hook(*) super @_halted_callback_hook_called = true diff --git a/activerecord/lib/active_record/railties/job_runtime.rb b/activerecord/lib/active_record/railties/job_runtime.rb index c34bc5c9f9908..f1d84ddf533cb 100644 --- a/activerecord/lib/active_record/railties/job_runtime.rb +++ b/activerecord/lib/active_record/railties/job_runtime.rb @@ -5,19 +5,18 @@ module ActiveRecord module Railties # :nodoc: module JobRuntime # :nodoc: - private - def instrument(operation, payload = {}, &block) - if operation == :perform && block - super(operation, payload) do - db_runtime_before_perform = ActiveRecord::RuntimeRegistry.sql_runtime - result = block.call - payload[:db_runtime] = ActiveRecord::RuntimeRegistry.sql_runtime - db_runtime_before_perform - result - end - else - super + def instrument(operation, payload = {}, &block) # :nodoc: + if operation == :perform && block + super(operation, payload) do + db_runtime_before_perform = ActiveRecord::RuntimeRegistry.sql_runtime + result = block.call + payload[:db_runtime] = ActiveRecord::RuntimeRegistry.sql_runtime - db_runtime_before_perform + result end + else + super end + end end end end From cf5eafa7f78786ed8f5a45f02c2bbb5d7f797d1b Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Tue, 3 Jun 2025 11:57:48 +0100 Subject: [PATCH 0208/1075] Avoid repeated calls to respond_to? The common case is that the class will implement `succ`, so catch NoMethodError's instead to avoid the repeated calls to `respond_to?`. Also add test coverage for advancing cursors. --- activejob/lib/active_job/continuation/step.rb | 10 ++++-- activejob/test/cases/continuation_test.rb | 36 +++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/activejob/lib/active_job/continuation/step.rb b/activejob/lib/active_job/continuation/step.rb index 4bb2ca357cca8..71f8ce63bbcc9 100644 --- a/activejob/lib/active_job/continuation/step.rb +++ b/activejob/lib/active_job/continuation/step.rb @@ -48,8 +48,14 @@ def set!(cursor) # An UnadvanceableCursorError error will be raised if the cursor does not implement +succ+. def advance!(from: nil) from = cursor if from.nil? - raise UnadvanceableCursorError, "Cursor class '#{from.class}' does not implement succ, " unless from.respond_to?(:succ) - set! from.succ + + begin + to = from.succ + rescue NoMethodError + raise UnadvanceableCursorError, "Cursor class '#{from.class}' does not implement 'succ'" + end + + set! to end # Has this step been resumed from a previous job execution? diff --git a/activejob/test/cases/continuation_test.rb b/activejob/test/cases/continuation_test.rb index dc1ae2a5bb93c..29bf834d9b75e 100644 --- a/activejob/test/cases/continuation_test.rb +++ b/activejob/test/cases/continuation_test.rb @@ -18,6 +18,10 @@ return unless adapter_is?(:test) +class ContinuableJob < ActiveJob::Base + include ActiveJob::Continuable +end + class ActiveJob::TestContinuation < ActiveSupport::TestCase include ActiveJob::Continuation::TestHelper include ActiveSupport::Testing::Stream @@ -251,6 +255,38 @@ class ActiveJob::TestContinuation < ActiveSupport::TestCase assert_equal "Step 'unexpected' found, expected to resume from 'iterating'", exception.message end + class ContinuableAdvancingJob < ContinuableJob + def perform(start_from, advance_from = nil) + step :test_step, start: start_from do |step| + step.advance! from: advance_from + end + end + end + + test "cursor must implement succ to advance" do + perform_enqueued_jobs do + assert_raises ActiveJob::Continuation::UnadvanceableCursorError do + ContinuableAdvancingJob.perform_later(nil) + end + + assert_raises ActiveJob::Continuation::UnadvanceableCursorError do + ContinuableAdvancingJob.perform_later(1.1) + end + + assert_raises ActiveJob::Continuation::UnadvanceableCursorError do + ContinuableAdvancingJob.perform_later(nil, 1.1) + end + + assert_nothing_raised do + ContinuableAdvancingJob.perform_later(1) + end + + assert_nothing_raised do + ContinuableAdvancingJob.perform_later(nil, 1) + end + end + end + test "deserializes a job with no continuation" do ContinuableDeletingJob.items = 10.times.map { |i| "item_#{i}" } ContinuableDeletingJob.perform_later From 024d1d66fe6a9a5fe97b2a69299ba4ffb4c0c552 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Tue, 3 Jun 2025 12:28:02 +0100 Subject: [PATCH 0209/1075] Move Continuable jobs into the test case --- activejob/test/cases/continuation_test.rb | 196 +++++++++++++++--- .../test/jobs/continuable_array_cursor_job.rb | 16 -- .../test/jobs/continuable_deleting_job.rb | 17 -- .../jobs/continuable_duplicate_step_job.rb | 12 -- .../test/jobs/continuable_iterating_job.rb | 28 --- activejob/test/jobs/continuable_linear_job.rb | 31 --- .../jobs/continuable_nested_cursor_job.rb | 21 -- .../test/jobs/continuable_nested_steps_job.rb | 16 -- .../jobs/continuable_resume_wrong_step_job.rb | 18 -- .../jobs/continuable_string_step_name_job.rb | 10 - 10 files changed, 164 insertions(+), 201 deletions(-) delete mode 100644 activejob/test/jobs/continuable_array_cursor_job.rb delete mode 100644 activejob/test/jobs/continuable_deleting_job.rb delete mode 100644 activejob/test/jobs/continuable_duplicate_step_job.rb delete mode 100644 activejob/test/jobs/continuable_iterating_job.rb delete mode 100644 activejob/test/jobs/continuable_linear_job.rb delete mode 100644 activejob/test/jobs/continuable_nested_cursor_job.rb delete mode 100644 activejob/test/jobs/continuable_nested_steps_job.rb delete mode 100644 activejob/test/jobs/continuable_resume_wrong_step_job.rb delete mode 100644 activejob/test/jobs/continuable_string_step_name_job.rb diff --git a/activejob/test/cases/continuation_test.rb b/activejob/test/cases/continuation_test.rb index 29bf834d9b75e..f7d997465d6ff 100644 --- a/activejob/test/cases/continuation_test.rb +++ b/activejob/test/cases/continuation_test.rb @@ -6,28 +6,43 @@ require "active_support/core_ext/object/with" require "support/test_logger" require "support/do_not_perform_enqueued_jobs" -require "jobs/continuable_array_cursor_job" -require "jobs/continuable_iterating_job" -require "jobs/continuable_linear_job" -require "jobs/continuable_deleting_job" -require "jobs/continuable_duplicate_step_job" -require "jobs/continuable_nested_steps_job" -require "jobs/continuable_string_step_name_job" -require "jobs/continuable_resume_wrong_step_job" -require "jobs/continuable_nested_cursor_job" return unless adapter_is?(:test) -class ContinuableJob < ActiveJob::Base - include ActiveJob::Continuable -end - class ActiveJob::TestContinuation < ActiveSupport::TestCase include ActiveJob::Continuation::TestHelper include ActiveSupport::Testing::Stream include DoNotPerformEnqueuedJobs include TestLoggerHelper + class ContinuableJob < ActiveJob::Base + include ActiveJob::Continuable + end + + ContinuableIteratingRecord = Struct.new(:id, :name) do + cattr_accessor :records + + def self.find_each(start: nil) + records.sort_by(&:id).each do |record| + next if start && record.id < start + + yield record + end + end + end + + class ContinuableIteratingJob < ContinuableJob + def perform(raise_when_cursor: nil) + step :rename do |step| + ContinuableIteratingRecord.find_each(start: step.cursor) do |record| + raise StandardError, "Cursor error" if raise_when_cursor && step.cursor == raise_when_cursor + record.name = "new_#{record.name}" + step.advance! from: record.id + end + end + end + end + test "iterates" do ContinuableIteratingRecord.records = [ 123, 432, 6565, 3243, 234, 13, 22 ].map { |i| ContinuableIteratingRecord.new(i, "item_#{i}") } @@ -60,6 +75,34 @@ class ActiveJob::TestContinuation < ActiveSupport::TestCase assert_equal %w[ new_item_123 new_item_432 new_item_6565 new_item_3243 new_item_234 new_item_13 new_item_22 ], ContinuableIteratingRecord.records.map(&:name) end + class ContinuableLinearJob < ContinuableJob + cattr_accessor :items + + def perform + step :step_one + step :step_two + step :step_three + step :step_four + end + + private + def step_one + items << "item1" + end + + def step_two + items << "item2" + end + + def step_three + items << "item3" + end + + def step_four + items << "item4" + end + end + test "linear steps" do ContinuableLinearJob.items = [] ContinuableLinearJob.perform_later @@ -97,6 +140,20 @@ class ActiveJob::TestContinuation < ActiveSupport::TestCase assert_equal %w[ item1 item2 item3 item4 ], ContinuableLinearJob.items end + class ContinuableDeletingJob < ContinuableJob + cattr_accessor :items + + def perform + step :delete do |step| + loop do + break if items.empty? + items.shift + step.checkpoint! + end + end + end + end + test "does not retry jobs that error without updating the cursor" do ContinuableDeletingJob.items = 10.times.map { |i| "item_#{i}" } ContinuableDeletingJob.perform_later @@ -112,6 +169,25 @@ class ActiveJob::TestContinuation < ActiveSupport::TestCase assert_equal %w[ item_1 item_2 item_3 item_4 item_5 item_6 item_7 item_8 item_9 ], ContinuableDeletingJob.items end + test "interrupts without cursors" do + ContinuableDeletingJob.items = 10.times.map { |i| "item_#{i}" } + ContinuableDeletingJob.perform_later + + interrupt_job_during_step ContinuableDeletingJob, :delete do + assert_enqueued_jobs 1, only: ContinuableDeletingJob do + perform_enqueued_jobs + end + end + + assert_equal 9, ContinuableDeletingJob.items.count + + assert_enqueued_jobs 0 do + perform_enqueued_jobs + end + + assert_equal 0, ContinuableDeletingJob.items.count + end + test "saves progress when there is an error" do ContinuableIteratingRecord.records = [ 123, 432, 6565, 3243, 234, 13, 22 ].map { |i| ContinuableIteratingRecord.new(i, "item_#{i}") } @@ -163,13 +239,13 @@ class ActiveJob::TestContinuation < ActiveSupport::TestCase assert_no_match "Resuming", @logger.messages assert_match(/Step 'step_one' started/, @logger.messages) assert_match(/Step 'step_one' completed/, @logger.messages) - assert_match(/Interrupted ContinuableLinearJob \(Job ID: [0-9a-f-]{36}\) after 'step_one'/, @logger.messages) + assert_match(/Interrupted ActiveJob::TestContinuation::ContinuableLinearJob \(Job ID: [0-9a-f-]{36}\) after 'step_one'/, @logger.messages) end perform_enqueued_jobs assert_match(/Step 'step_one' skipped/, @logger.messages) - assert_match(/Resuming ContinuableLinearJob \(Job ID: [0-9a-f-]{36}\) after 'step_one'/, @logger.messages) + assert_match(/Resuming ActiveJob::TestContinuation::ContinuableLinearJob \(Job ID: [0-9a-f-]{36}\) after 'step_one'/, @logger.messages) assert_match(/Step 'step_two' started/, @logger.messages) assert_match(/Step 'step_two' completed/, @logger.messages) end @@ -183,32 +259,22 @@ class ActiveJob::TestContinuation < ActiveSupport::TestCase assert_no_match "Resuming", @logger.messages assert_match(/Step 'rename' started/, @logger.messages) assert_match(/Step 'rename' interrupted at cursor '433'/, @logger.messages) - assert_match(/Interrupted ContinuableIteratingJob \(Job ID: [0-9a-f-]{36}\) at 'rename', cursor '433'/, @logger.messages) + assert_match(/Interrupted ActiveJob::TestContinuation::ContinuableIteratingJob \(Job ID: [0-9a-f-]{36}\) at 'rename', cursor '433'/, @logger.messages) end perform_enqueued_jobs - assert_match(/Resuming ContinuableIteratingJob \(Job ID: [0-9a-f-]{36}\) at 'rename', cursor '433'/, @logger.messages) + assert_match(/Resuming ActiveJob::TestContinuation::ContinuableIteratingJob \(Job ID: [0-9a-f-]{36}\) at 'rename', cursor '433'/, @logger.messages) assert_match(/Step 'rename' resumed from cursor '433'/, @logger.messages) assert_match(/Step 'rename' completed/, @logger.messages) end - test "interrupts without cursors" do - ContinuableDeletingJob.items = 10.times.map { |i| "item_#{i}" } - ContinuableDeletingJob.perform_later - - interrupt_job_during_step ContinuableDeletingJob, :delete do - assert_enqueued_jobs 1, only: ContinuableDeletingJob do - perform_enqueued_jobs + class ContinuableDuplicateStepJob < ContinuableJob + def perform + step :duplicate do |step| + end + step :duplicate do |step| end end - - assert_equal 9, ContinuableDeletingJob.items.count - - assert_enqueued_jobs 0 do - perform_enqueued_jobs - end - - assert_equal 0, ContinuableDeletingJob.items.count end test "duplicate steps raise an error" do @@ -221,6 +287,19 @@ class ActiveJob::TestContinuation < ActiveSupport::TestCase assert_equal "Step 'duplicate' has already been encountered", exception.message end + class ContinuableNestedStepsJob < ContinuableJob + def perform + step :outer_step do + # Not allowed! + step :inner_step do + end + end + end + + private + def inner_step; end + end + test "nested steps raise an error" do ContinuableNestedStepsJob.perform_later @@ -231,6 +310,13 @@ class ActiveJob::TestContinuation < ActiveSupport::TestCase assert_equal "Step 'inner_step' is nested inside step 'outer_step'", exception.message end + class ContinuableStringStepNameJob < ContinuableJob + def perform + step "string_step_name" do + end + end + end + test "string named steps raise an error" do ContinuableStringStepNameJob.perform_later @@ -241,6 +327,21 @@ class ActiveJob::TestContinuation < ActiveSupport::TestCase assert_equal "Step 'string_step_name' must be a Symbol, found 'String'", exception.message end + class ContinuableResumeWrongStepJob < ContinuableJob + def perform + if continuation.send(:started?) + step :unexpected do |step| + end + else + step :iterating, start: 0 do |step| + ((step.cursor || 1)..4).each do |i| + step.advance! + end + end + end + end + end + test "unexpected step on resumption raises an error" do ContinuableResumeWrongStepJob.perform_later @@ -300,6 +401,24 @@ def perform(start_from, advance_from = nil) assert_equal 0, ContinuableDeletingJob.items.count end + class ContinuableNestedCursorJob < ContinuableJob + cattr_accessor :items + + def perform + step :updating_sub_items, start: [ 0, 0 ] do |step| + items[step.cursor[0]..].each do |inner_items| + inner_items[step.cursor[1]..].each do |item| + items[step.cursor[0]][step.cursor[1]] = "new_#{item}" + + step.set! [ step.cursor[0], step.cursor[1] + 1 ] + end + + step.set! [ step.cursor[0] + 1, 0 ] + end + end + end + end + test "nested cursor" do ContinuableNestedCursorJob.items = [ 3.times.map { |i| "subitem_0_#{i}" }, @@ -339,6 +458,19 @@ def perform(start_from, advance_from = nil) assert_equal [ %w[ new_subitem_0_0 new_subitem_0_1 new_subitem_0_2 ], %w[ new_subitem_1_0 ], %w[ new_subitem_2_0 new_subitem_2_1 ] ], ContinuableNestedCursorJob.items end + class ContinuableArrayCursorJob < ContinuableJob + cattr_accessor :items, default: [] + + def perform(objects) + step :iterate_objects, start: 0 do |step| + objects[step.cursor..].each do |object| + items << object + step.advance! + end + end + end + end + test "iterates over array cursor" do ContinuableArrayCursorJob.items = [] diff --git a/activejob/test/jobs/continuable_array_cursor_job.rb b/activejob/test/jobs/continuable_array_cursor_job.rb deleted file mode 100644 index 80efab50a2cde..0000000000000 --- a/activejob/test/jobs/continuable_array_cursor_job.rb +++ /dev/null @@ -1,16 +0,0 @@ -# frozen_string_literal: true - -class ContinuableArrayCursorJob < ActiveJob::Base - include ActiveJob::Continuable - - cattr_accessor :items, default: [] - - def perform(objects) - step :iterate_objects, start: 0 do |step| - objects[step.cursor..].each do |object| - items << object - step.advance! - end - end - end -end diff --git a/activejob/test/jobs/continuable_deleting_job.rb b/activejob/test/jobs/continuable_deleting_job.rb deleted file mode 100644 index 40a2d6008dc6c..0000000000000 --- a/activejob/test/jobs/continuable_deleting_job.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true - -class ContinuableDeletingJob < ActiveJob::Base - include ActiveJob::Continuable - - cattr_accessor :items - - def perform - step :delete do |step| - loop do - break if items.empty? - items.shift - step.checkpoint! - end - end - end -end diff --git a/activejob/test/jobs/continuable_duplicate_step_job.rb b/activejob/test/jobs/continuable_duplicate_step_job.rb deleted file mode 100644 index 27ad13a2b3c1c..0000000000000 --- a/activejob/test/jobs/continuable_duplicate_step_job.rb +++ /dev/null @@ -1,12 +0,0 @@ -# frozen_string_literal: true - -class ContinuableDuplicateStepJob < ActiveJob::Base - include ActiveJob::Continuable - - def perform - step :duplicate do |step| - end - step :duplicate do |step| - end - end -end diff --git a/activejob/test/jobs/continuable_iterating_job.rb b/activejob/test/jobs/continuable_iterating_job.rb deleted file mode 100644 index d6dc829ecf4c7..0000000000000 --- a/activejob/test/jobs/continuable_iterating_job.rb +++ /dev/null @@ -1,28 +0,0 @@ -# frozen_string_literal: true - -# Rough approximation of the AR batch iterator -ContinuableIteratingRecord = Struct.new(:id, :name) do - cattr_accessor :records - - def self.find_each(start: nil) - records.sort_by(&:id).each do |record| - next if start && record.id < start - - yield record - end - end -end - -class ContinuableIteratingJob < ActiveJob::Base - include ActiveJob::Continuable - - def perform(raise_when_cursor: nil) - step :rename do |step| - ContinuableIteratingRecord.find_each(start: step.cursor) do |record| - raise StandardError, "Cursor error" if raise_when_cursor && step.cursor == raise_when_cursor - record.name = "new_#{record.name}" - step.advance! from: record.id - end - end - end -end diff --git a/activejob/test/jobs/continuable_linear_job.rb b/activejob/test/jobs/continuable_linear_job.rb deleted file mode 100644 index 24f87ffa9f50d..0000000000000 --- a/activejob/test/jobs/continuable_linear_job.rb +++ /dev/null @@ -1,31 +0,0 @@ -# frozen_string_literal: true - -class ContinuableLinearJob < ActiveJob::Base - include ActiveJob::Continuable - - cattr_accessor :items - - def perform - step :step_one - step :step_two - step :step_three - step :step_four - end - - private - def step_one - items << "item1" - end - - def step_two - items << "item2" - end - - def step_three - items << "item3" - end - - def step_four - items << "item4" - end -end diff --git a/activejob/test/jobs/continuable_nested_cursor_job.rb b/activejob/test/jobs/continuable_nested_cursor_job.rb deleted file mode 100644 index 851af290781f0..0000000000000 --- a/activejob/test/jobs/continuable_nested_cursor_job.rb +++ /dev/null @@ -1,21 +0,0 @@ -# frozen_string_literal: true - -class ContinuableNestedCursorJob < ActiveJob::Base - include ActiveJob::Continuable - - cattr_accessor :items - - def perform - step :updating_sub_items, start: [ 0, 0 ] do |step| - items[step.cursor[0]..].each do |inner_items| - inner_items[step.cursor[1]..].each do |item| - items[step.cursor[0]][step.cursor[1]] = "new_#{item}" - - step.set! [ step.cursor[0], step.cursor[1] + 1 ] - end - - step.set! [ step.cursor[0] + 1, 0 ] - end - end - end -end diff --git a/activejob/test/jobs/continuable_nested_steps_job.rb b/activejob/test/jobs/continuable_nested_steps_job.rb deleted file mode 100644 index 2e0952cc4c88d..0000000000000 --- a/activejob/test/jobs/continuable_nested_steps_job.rb +++ /dev/null @@ -1,16 +0,0 @@ -# frozen_string_literal: true - -class ContinuableNestedStepsJob < ActiveJob::Base - include ActiveJob::Continuable - - def perform - step :outer_step do - # Not allowed! - step :inner_step do - end - end - end - - private - def inner_step; end -end diff --git a/activejob/test/jobs/continuable_resume_wrong_step_job.rb b/activejob/test/jobs/continuable_resume_wrong_step_job.rb deleted file mode 100644 index ec32fb79fed3b..0000000000000 --- a/activejob/test/jobs/continuable_resume_wrong_step_job.rb +++ /dev/null @@ -1,18 +0,0 @@ -# frozen_string_literal: true - -class ContinuableResumeWrongStepJob < ActiveJob::Base - include ActiveJob::Continuable - - def perform - if continuation.send(:started?) - step :unexpected do |step| - end - else - step :iterating, start: 0 do |step| - ((step.cursor || 1)..4).each do |i| - step.advance! - end - end - end - end -end diff --git a/activejob/test/jobs/continuable_string_step_name_job.rb b/activejob/test/jobs/continuable_string_step_name_job.rb deleted file mode 100644 index b4ee0cdf8e267..0000000000000 --- a/activejob/test/jobs/continuable_string_step_name_job.rb +++ /dev/null @@ -1,10 +0,0 @@ -# frozen_string_literal: true - -class ContinuableStringStepNameJob < ActiveJob::Base - include ActiveJob::Continuable - - def perform - step "string_step_name" do |step| - end - end -end From 0b88f1726471a65937dd1f5b7acc84a9083d6b11 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Wed, 4 Jun 2025 11:20:33 +0100 Subject: [PATCH 0210/1075] Remove redundant Continuable job prefixes --- activejob/test/cases/continuation_test.rb | 192 +++++++++++----------- 1 file changed, 96 insertions(+), 96 deletions(-) diff --git a/activejob/test/cases/continuation_test.rb b/activejob/test/cases/continuation_test.rb index f7d997465d6ff..3bf81f4845e26 100644 --- a/activejob/test/cases/continuation_test.rb +++ b/activejob/test/cases/continuation_test.rb @@ -19,7 +19,7 @@ class ContinuableJob < ActiveJob::Base include ActiveJob::Continuable end - ContinuableIteratingRecord = Struct.new(:id, :name) do + IteratingRecord = Struct.new(:id, :name) do cattr_accessor :records def self.find_each(start: nil) @@ -31,10 +31,10 @@ def self.find_each(start: nil) end end - class ContinuableIteratingJob < ContinuableJob + class IteratingJob < ContinuableJob def perform(raise_when_cursor: nil) step :rename do |step| - ContinuableIteratingRecord.find_each(start: step.cursor) do |record| + IteratingRecord.find_each(start: step.cursor) do |record| raise StandardError, "Cursor error" if raise_when_cursor && step.cursor == raise_when_cursor record.name = "new_#{record.name}" step.advance! from: record.id @@ -44,38 +44,38 @@ def perform(raise_when_cursor: nil) end test "iterates" do - ContinuableIteratingRecord.records = [ 123, 432, 6565, 3243, 234, 13, 22 ].map { |i| ContinuableIteratingRecord.new(i, "item_#{i}") } + IteratingRecord.records = [ 123, 432, 6565, 3243, 234, 13, 22 ].map { |i| IteratingRecord.new(i, "item_#{i}") } - ContinuableIteratingJob.perform_later + IteratingJob.perform_later - assert_enqueued_jobs 0, only: ContinuableIteratingJob do + assert_enqueued_jobs 0, only: IteratingJob do perform_enqueued_jobs end - assert_equal %w[ new_item_123 new_item_432 new_item_6565 new_item_3243 new_item_234 new_item_13 new_item_22 ], ContinuableIteratingRecord.records.map(&:name) + assert_equal %w[ new_item_123 new_item_432 new_item_6565 new_item_3243 new_item_234 new_item_13 new_item_22 ], IteratingRecord.records.map(&:name) end test "iterates and continues" do - ContinuableIteratingRecord.records = [ 123, 432, 6565, 3243, 234, 13, 22 ].map { |i| ContinuableIteratingRecord.new(i, "item_#{i}") } + IteratingRecord.records = [ 123, 432, 6565, 3243, 234, 13, 22 ].map { |i| IteratingRecord.new(i, "item_#{i}") } - ContinuableIteratingJob.perform_later + IteratingJob.perform_later - interrupt_job_during_step ContinuableIteratingJob, :rename, cursor: 433 do - assert_enqueued_jobs 1, only: ContinuableIteratingJob do + interrupt_job_during_step IteratingJob, :rename, cursor: 433 do + assert_enqueued_jobs 1, only: IteratingJob do perform_enqueued_jobs end end - assert_equal %w[ new_item_123 new_item_432 item_6565 item_3243 new_item_234 new_item_13 new_item_22 ], ContinuableIteratingRecord.records.map(&:name) + assert_equal %w[ new_item_123 new_item_432 item_6565 item_3243 new_item_234 new_item_13 new_item_22 ], IteratingRecord.records.map(&:name) assert_enqueued_jobs 0 do perform_enqueued_jobs end - assert_equal %w[ new_item_123 new_item_432 new_item_6565 new_item_3243 new_item_234 new_item_13 new_item_22 ], ContinuableIteratingRecord.records.map(&:name) + assert_equal %w[ new_item_123 new_item_432 new_item_6565 new_item_3243 new_item_234 new_item_13 new_item_22 ], IteratingRecord.records.map(&:name) end - class ContinuableLinearJob < ContinuableJob + class LinearJob < ContinuableJob cattr_accessor :items def perform @@ -104,43 +104,43 @@ def step_four end test "linear steps" do - ContinuableLinearJob.items = [] - ContinuableLinearJob.perform_later + LinearJob.items = [] + LinearJob.perform_later assert_enqueued_jobs 0 do perform_enqueued_jobs end - assert_equal %w[ item1 item2 item3 item4 ], ContinuableLinearJob.items + assert_equal %w[ item1 item2 item3 item4 ], LinearJob.items end test "linear steps continues from last point" do - ContinuableLinearJob.items = [] - ContinuableLinearJob.perform_later + LinearJob.items = [] + LinearJob.perform_later - interrupt_job_after_step ContinuableLinearJob, :step_one do - assert_enqueued_jobs 1, only: ContinuableLinearJob do + interrupt_job_after_step LinearJob, :step_one do + assert_enqueued_jobs 1, only: LinearJob do perform_enqueued_jobs end end - assert_equal %w[ item1 ], ContinuableLinearJob.items + assert_equal %w[ item1 ], LinearJob.items assert_enqueued_jobs 0 do perform_enqueued_jobs end - assert_equal %w[ item1 item2 item3 item4 ], ContinuableLinearJob.items + assert_equal %w[ item1 item2 item3 item4 ], LinearJob.items end test "runs with perform_now" do - ContinuableLinearJob.items = [] - ContinuableLinearJob.perform_now + LinearJob.items = [] + LinearJob.perform_now - assert_equal %w[ item1 item2 item3 item4 ], ContinuableLinearJob.items + assert_equal %w[ item1 item2 item3 item4 ], LinearJob.items end - class ContinuableDeletingJob < ContinuableJob + class DeletingJob < ContinuableJob cattr_accessor :items def perform @@ -155,46 +155,46 @@ def perform end test "does not retry jobs that error without updating the cursor" do - ContinuableDeletingJob.items = 10.times.map { |i| "item_#{i}" } - ContinuableDeletingJob.perform_later + DeletingJob.items = 10.times.map { |i| "item_#{i}" } + DeletingJob.perform_later - assert_enqueued_jobs 0, only: ContinuableDeletingJob do + assert_enqueued_jobs 0, only: DeletingJob do assert_raises StandardError do - queue_adapter.with(stopping: ->() { raise StandardError if during_step?(ContinuableDeletingJob, :delete) }) do + queue_adapter.with(stopping: ->() { raise StandardError if during_step?(DeletingJob, :delete) }) do perform_enqueued_jobs end end end - assert_equal %w[ item_1 item_2 item_3 item_4 item_5 item_6 item_7 item_8 item_9 ], ContinuableDeletingJob.items + assert_equal %w[ item_1 item_2 item_3 item_4 item_5 item_6 item_7 item_8 item_9 ], DeletingJob.items end test "interrupts without cursors" do - ContinuableDeletingJob.items = 10.times.map { |i| "item_#{i}" } - ContinuableDeletingJob.perform_later + DeletingJob.items = 10.times.map { |i| "item_#{i}" } + DeletingJob.perform_later - interrupt_job_during_step ContinuableDeletingJob, :delete do - assert_enqueued_jobs 1, only: ContinuableDeletingJob do + interrupt_job_during_step DeletingJob, :delete do + assert_enqueued_jobs 1, only: DeletingJob do perform_enqueued_jobs end end - assert_equal 9, ContinuableDeletingJob.items.count + assert_equal 9, DeletingJob.items.count assert_enqueued_jobs 0 do perform_enqueued_jobs end - assert_equal 0, ContinuableDeletingJob.items.count + assert_equal 0, DeletingJob.items.count end test "saves progress when there is an error" do - ContinuableIteratingRecord.records = [ 123, 432, 6565, 3243, 234, 13, 22 ].map { |i| ContinuableIteratingRecord.new(i, "item_#{i}") } + IteratingRecord.records = [ 123, 432, 6565, 3243, 234, 13, 22 ].map { |i| IteratingRecord.new(i, "item_#{i}") } - ContinuableIteratingJob.perform_later + IteratingJob.perform_later - queue_adapter.with(stopping: ->() { raise StandardError if during_step?(ContinuableIteratingJob, :rename, cursor: 433) }) do - assert_enqueued_jobs 1, only: ContinuableIteratingJob do + queue_adapter.with(stopping: ->() { raise StandardError if during_step?(IteratingJob, :rename, cursor: 433) }) do + assert_enqueued_jobs 1, only: IteratingJob do perform_enqueued_jobs end end @@ -202,28 +202,28 @@ def perform job = queue_adapter.enqueued_jobs.first assert_equal 1, job["executions"] - assert_equal %w[ new_item_123 new_item_432 item_6565 item_3243 new_item_234 new_item_13 new_item_22 ], ContinuableIteratingRecord.records.map(&:name) + assert_equal %w[ new_item_123 new_item_432 item_6565 item_3243 new_item_234 new_item_13 new_item_22 ], IteratingRecord.records.map(&:name) assert_enqueued_jobs 0 do perform_enqueued_jobs end - assert_equal %w[ new_item_123 new_item_432 new_item_6565 new_item_3243 new_item_234 new_item_13 new_item_22 ], ContinuableIteratingRecord.records.map(&:name) + assert_equal %w[ new_item_123 new_item_432 new_item_6565 new_item_3243 new_item_234 new_item_13 new_item_22 ], IteratingRecord.records.map(&:name) end test "does not retry a second error if the cursor did not advance" do - ContinuableIteratingRecord.records = [ 123, 432, 6565, 3243, 234, 13, 22 ].map { |i| ContinuableIteratingRecord.new(i, "item_#{i}") } + IteratingRecord.records = [ 123, 432, 6565, 3243, 234, 13, 22 ].map { |i| IteratingRecord.new(i, "item_#{i}") } - ContinuableIteratingJob.perform_later(raise_when_cursor: 433) + IteratingJob.perform_later(raise_when_cursor: 433) - assert_enqueued_jobs 1, only: ContinuableIteratingJob do + assert_enqueued_jobs 1, only: IteratingJob do perform_enqueued_jobs end job = queue_adapter.enqueued_jobs.first assert_equal 1, job["executions"] - assert_enqueued_jobs 0, only: ContinuableIteratingJob do + assert_enqueued_jobs 0, only: IteratingJob do assert_raises StandardError do perform_enqueued_jobs end @@ -231,44 +231,44 @@ def perform end test "logs interruptions after steps" do - ContinuableLinearJob.items = [] - ContinuableLinearJob.perform_later + LinearJob.items = [] + LinearJob.perform_later - interrupt_job_after_step ContinuableLinearJob, :step_one do + interrupt_job_after_step LinearJob, :step_one do perform_enqueued_jobs assert_no_match "Resuming", @logger.messages assert_match(/Step 'step_one' started/, @logger.messages) assert_match(/Step 'step_one' completed/, @logger.messages) - assert_match(/Interrupted ActiveJob::TestContinuation::ContinuableLinearJob \(Job ID: [0-9a-f-]{36}\) after 'step_one'/, @logger.messages) + assert_match(/Interrupted ActiveJob::TestContinuation::LinearJob \(Job ID: [0-9a-f-]{36}\) after 'step_one'/, @logger.messages) end perform_enqueued_jobs assert_match(/Step 'step_one' skipped/, @logger.messages) - assert_match(/Resuming ActiveJob::TestContinuation::ContinuableLinearJob \(Job ID: [0-9a-f-]{36}\) after 'step_one'/, @logger.messages) + assert_match(/Resuming ActiveJob::TestContinuation::LinearJob \(Job ID: [0-9a-f-]{36}\) after 'step_one'/, @logger.messages) assert_match(/Step 'step_two' started/, @logger.messages) assert_match(/Step 'step_two' completed/, @logger.messages) end test "logs interruptions during steps" do - ContinuableIteratingRecord.records = [ 123, 432, 6565, 3243, 234, 13, 22 ].map { |i| ContinuableIteratingRecord.new(i, "item_#{i}") } - ContinuableIteratingJob.perform_later + IteratingRecord.records = [ 123, 432, 6565, 3243, 234, 13, 22 ].map { |i| IteratingRecord.new(i, "item_#{i}") } + IteratingJob.perform_later - interrupt_job_during_step ContinuableIteratingJob, :rename, cursor: 433 do + interrupt_job_during_step IteratingJob, :rename, cursor: 433 do perform_enqueued_jobs assert_no_match "Resuming", @logger.messages assert_match(/Step 'rename' started/, @logger.messages) assert_match(/Step 'rename' interrupted at cursor '433'/, @logger.messages) - assert_match(/Interrupted ActiveJob::TestContinuation::ContinuableIteratingJob \(Job ID: [0-9a-f-]{36}\) at 'rename', cursor '433'/, @logger.messages) + assert_match(/Interrupted ActiveJob::TestContinuation::IteratingJob \(Job ID: [0-9a-f-]{36}\) at 'rename', cursor '433'/, @logger.messages) end perform_enqueued_jobs - assert_match(/Resuming ActiveJob::TestContinuation::ContinuableIteratingJob \(Job ID: [0-9a-f-]{36}\) at 'rename', cursor '433'/, @logger.messages) + assert_match(/Resuming ActiveJob::TestContinuation::IteratingJob \(Job ID: [0-9a-f-]{36}\) at 'rename', cursor '433'/, @logger.messages) assert_match(/Step 'rename' resumed from cursor '433'/, @logger.messages) assert_match(/Step 'rename' completed/, @logger.messages) end - class ContinuableDuplicateStepJob < ContinuableJob + class DuplicateStepJob < ContinuableJob def perform step :duplicate do |step| end @@ -278,7 +278,7 @@ def perform end test "duplicate steps raise an error" do - ContinuableDuplicateStepJob.perform_later + DuplicateStepJob.perform_later exception = assert_raises ActiveJob::Continuation::InvalidStepError do perform_enqueued_jobs @@ -287,7 +287,7 @@ def perform assert_equal "Step 'duplicate' has already been encountered", exception.message end - class ContinuableNestedStepsJob < ContinuableJob + class NestedStepsJob < ContinuableJob def perform step :outer_step do # Not allowed! @@ -301,7 +301,7 @@ def inner_step; end end test "nested steps raise an error" do - ContinuableNestedStepsJob.perform_later + NestedStepsJob.perform_later exception = assert_raises ActiveJob::Continuation::InvalidStepError do perform_enqueued_jobs @@ -310,7 +310,7 @@ def inner_step; end assert_equal "Step 'inner_step' is nested inside step 'outer_step'", exception.message end - class ContinuableStringStepNameJob < ContinuableJob + class StringStepNameJob < ContinuableJob def perform step "string_step_name" do end @@ -318,7 +318,7 @@ def perform end test "string named steps raise an error" do - ContinuableStringStepNameJob.perform_later + StringStepNameJob.perform_later exception = assert_raises ActiveJob::Continuation::InvalidStepError do perform_enqueued_jobs @@ -327,7 +327,7 @@ def perform assert_equal "Step 'string_step_name' must be a Symbol, found 'String'", exception.message end - class ContinuableResumeWrongStepJob < ContinuableJob + class ResumeWrongStepJob < ContinuableJob def perform if continuation.send(:started?) step :unexpected do |step| @@ -343,9 +343,9 @@ def perform end test "unexpected step on resumption raises an error" do - ContinuableResumeWrongStepJob.perform_later + ResumeWrongStepJob.perform_later - interrupt_job_during_step ContinuableResumeWrongStepJob, :iterating, cursor: 2 do + interrupt_job_during_step ResumeWrongStepJob, :iterating, cursor: 2 do perform_enqueued_jobs end @@ -356,7 +356,7 @@ def perform assert_equal "Step 'unexpected' found, expected to resume from 'iterating'", exception.message end - class ContinuableAdvancingJob < ContinuableJob + class AdvancingJob < ContinuableJob def perform(start_from, advance_from = nil) step :test_step, start: start_from do |step| step.advance! from: advance_from @@ -367,30 +367,30 @@ def perform(start_from, advance_from = nil) test "cursor must implement succ to advance" do perform_enqueued_jobs do assert_raises ActiveJob::Continuation::UnadvanceableCursorError do - ContinuableAdvancingJob.perform_later(nil) + AdvancingJob.perform_later(nil) end assert_raises ActiveJob::Continuation::UnadvanceableCursorError do - ContinuableAdvancingJob.perform_later(1.1) + AdvancingJob.perform_later(1.1) end assert_raises ActiveJob::Continuation::UnadvanceableCursorError do - ContinuableAdvancingJob.perform_later(nil, 1.1) + AdvancingJob.perform_later(nil, 1.1) end assert_nothing_raised do - ContinuableAdvancingJob.perform_later(1) + AdvancingJob.perform_later(1) end assert_nothing_raised do - ContinuableAdvancingJob.perform_later(nil, 1) + AdvancingJob.perform_later(nil, 1) end end end test "deserializes a job with no continuation" do - ContinuableDeletingJob.items = 10.times.map { |i| "item_#{i}" } - ContinuableDeletingJob.perform_later + DeletingJob.items = 10.times.map { |i| "item_#{i}" } + DeletingJob.perform_later queue_adapter.enqueued_jobs.each { |job| job.delete("continuation") } @@ -398,10 +398,10 @@ def perform(start_from, advance_from = nil) perform_enqueued_jobs end - assert_equal 0, ContinuableDeletingJob.items.count + assert_equal 0, DeletingJob.items.count end - class ContinuableNestedCursorJob < ContinuableJob + class NestedCursorJob < ContinuableJob cattr_accessor :items def perform @@ -420,45 +420,45 @@ def perform end test "nested cursor" do - ContinuableNestedCursorJob.items = [ + NestedCursorJob.items = [ 3.times.map { |i| "subitem_0_#{i}" }, 1.times.map { |i| "subitem_1_#{i}" }, 2.times.map { |i| "subitem_2_#{i}" } ] - ContinuableNestedCursorJob.perform_later + NestedCursorJob.perform_later assert_enqueued_jobs 0 do perform_enqueued_jobs end - assert_equal [ %w[ new_subitem_0_0 new_subitem_0_1 new_subitem_0_2 ], %w[ new_subitem_1_0 ], %w[ new_subitem_2_0 new_subitem_2_1 ] ], ContinuableNestedCursorJob.items + assert_equal [ %w[ new_subitem_0_0 new_subitem_0_1 new_subitem_0_2 ], %w[ new_subitem_1_0 ], %w[ new_subitem_2_0 new_subitem_2_1 ] ], NestedCursorJob.items end test "nested cursor resumes" do - ContinuableNestedCursorJob.items = [ + NestedCursorJob.items = [ 3.times.map { |i| "subitem_0_#{i}" }, 1.times.map { |i| "subitem_1_#{i}" }, 2.times.map { |i| "subitem_2_#{i}" } ] - ContinuableNestedCursorJob.perform_later + NestedCursorJob.perform_later - interrupt_job_during_step ContinuableNestedCursorJob, :updating_sub_items, cursor: [ 0, 2 ] do + interrupt_job_during_step NestedCursorJob, :updating_sub_items, cursor: [ 0, 2 ] do assert_enqueued_jobs 1 do perform_enqueued_jobs end end - assert_equal [ %w[ new_subitem_0_0 new_subitem_0_1 subitem_0_2 ], %w[ subitem_1_0 ], %w[ subitem_2_0 subitem_2_1 ] ], ContinuableNestedCursorJob.items + assert_equal [ %w[ new_subitem_0_0 new_subitem_0_1 subitem_0_2 ], %w[ subitem_1_0 ], %w[ subitem_2_0 subitem_2_1 ] ], NestedCursorJob.items assert_enqueued_jobs 0 do perform_enqueued_jobs end - assert_equal [ %w[ new_subitem_0_0 new_subitem_0_1 new_subitem_0_2 ], %w[ new_subitem_1_0 ], %w[ new_subitem_2_0 new_subitem_2_1 ] ], ContinuableNestedCursorJob.items + assert_equal [ %w[ new_subitem_0_0 new_subitem_0_1 new_subitem_0_2 ], %w[ new_subitem_1_0 ], %w[ new_subitem_2_0 new_subitem_2_1 ] ], NestedCursorJob.items end - class ContinuableArrayCursorJob < ContinuableJob + class ArrayCursorJob < ContinuableJob cattr_accessor :items, default: [] def perform(objects) @@ -472,39 +472,39 @@ def perform(objects) end test "iterates over array cursor" do - ContinuableArrayCursorJob.items = [] + ArrayCursorJob.items = [] objects = [ :hello, "world", 1, 1.2, nil, true, false, [ 1, 2, 3 ], { a: 1, b: 2, c: 3 } ] - ContinuableArrayCursorJob.perform_later(objects) + ArrayCursorJob.perform_later(objects) assert_enqueued_jobs 0 do perform_enqueued_jobs end - assert_equal objects, ContinuableArrayCursorJob.items + assert_equal objects, ArrayCursorJob.items end test "interrupts and resumes array cursor" do - ContinuableArrayCursorJob.items = [] + ArrayCursorJob.items = [] objects = [ :hello, "world", 1, 1.2, nil, true, false, [ 1, 2, 3 ], { a: 1, b: 2, c: 3 } ] - ContinuableArrayCursorJob.perform_later(objects) + ArrayCursorJob.perform_later(objects) - assert_enqueued_jobs 1, only: ContinuableArrayCursorJob do - interrupt_job_during_step ContinuableArrayCursorJob, :iterate_objects, cursor: 3 do + assert_enqueued_jobs 1, only: ArrayCursorJob do + interrupt_job_during_step ArrayCursorJob, :iterate_objects, cursor: 3 do perform_enqueued_jobs end end - assert_equal objects[0...3], ContinuableArrayCursorJob.items + assert_equal objects[0...3], ArrayCursorJob.items - assert_enqueued_jobs 0, only: ContinuableArrayCursorJob do + assert_enqueued_jobs 0, only: ArrayCursorJob do perform_enqueued_jobs end - assert_equal objects, ContinuableArrayCursorJob.items + assert_equal objects, ArrayCursorJob.items end private From dce59e8fa47e816ead95a925ff62398fe44e5df5 Mon Sep 17 00:00:00 2001 From: Hartley McGuire Date: Tue, 3 Jun 2025 22:46:49 -0400 Subject: [PATCH 0211/1075] Fix {Public,Debug}Exceptions body for HEAD Rack::Lint already errors in this case, it just wasn't being covered. ``` Error: DebugExceptionsTest#test_returns_empty_body_on_HEAD_cascade_pass: Rack::Lint::LintError: Response body was given for HEAD request, but should be empty /home/hartley/.local/share/mise/installs/ruby/3.4.4/lib/ruby/gems/3.4.0/gems/rack-3.1.15/lib/rack/lint.rb:773:in 'Rack::Lint::Wrapper#verify_content_length' /home/hartley/.local/share/mise/installs/ruby/3.4.4/lib/ruby/gems/3.4.0/gems/rack-3.1.15/lib/rack/lint.rb:899:in 'Rack::Lint::Wrapper#each' /home/hartley/.local/share/mise/installs/ruby/3.4.4/lib/ruby/gems/3.4.0/gems/rack-3.1.15/lib/rack/response.rb:345:in 'Rack::Response::Helpers#buffered_body!' /home/hartley/.local/share/mise/installs/ruby/3.4.4/lib/ruby/gems/3.4.0/gems/rack-3.1.15/lib/rack/mock_response.rb:65:in 'Rack::MockResponse#initialize' /home/hartley/.local/share/mise/installs/ruby/3.4.4/lib/ruby/gems/3.4.0/gems/rack-test-2.2.0/lib/rack/test.rb:362:in 'Class#new' /home/hartley/.local/share/mise/installs/ruby/3.4.4/lib/ruby/gems/3.4.0/gems/rack-test-2.2.0/lib/rack/test.rb:362:in 'Rack::Test::Session#process_request' /home/hartley/.local/share/mise/installs/ruby/3.4.4/lib/ruby/gems/3.4.0/gems/rack-test-2.2.0/lib/rack/test.rb:153:in 'Rack::Test::Session#request' lib/action_dispatch/testing/integration.rb:297:in 'ActionDispatch::Integration::Session#process' lib/action_dispatch/testing/integration.rb:49:in 'ActionDispatch::Integration::RequestHelpers#head' lib/action_dispatch/testing/integration.rb:388:in 'ActionDispatch::Integration::Runner#head' test/dispatch/debug_exceptions_test.rb:189:in 'block in ' Error: ShowExceptionsTest#test_rescue_with_no_body_for_HEAD_requests: Rack::Lint::LintError: Response body was given for HEAD request, but should be empty /home/hartley/.local/share/mise/installs/ruby/3.4.4/lib/ruby/gems/3.4.0/gems/rack-3.1.15/lib/rack/lint.rb:773:in 'Rack::Lint::Wrapper#verify_content_length' /home/hartley/.local/share/mise/installs/ruby/3.4.4/lib/ruby/gems/3.4.0/gems/rack-3.1.15/lib/rack/lint.rb:900:in 'Rack::Lint::Wrapper#each' /home/hartley/.local/share/mise/installs/ruby/3.4.4/lib/ruby/gems/3.4.0/gems/rack-3.1.15/lib/rack/response.rb:345:in 'Rack::Response::Helpers#buffered_body!' /home/hartley/.local/share/mise/installs/ruby/3.4.4/lib/ruby/gems/3.4.0/gems/rack-3.1.15/lib/rack/mock_response.rb:65:in 'Rack::MockResponse#initialize' /home/hartley/.local/share/mise/installs/ruby/3.4.4/lib/ruby/gems/3.4.0/gems/rack-test-2.2.0/lib/rack/test.rb:362:in 'Class#new' /home/hartley/.local/share/mise/installs/ruby/3.4.4/lib/ruby/gems/3.4.0/gems/rack-test-2.2.0/lib/rack/test.rb:362:in 'Rack::Test::Session#process_request' /home/hartley/.local/share/mise/installs/ruby/3.4.4/lib/ruby/gems/3.4.0/gems/rack-test-2.2.0/lib/rack/test.rb:153:in 'Rack::Test::Session#request' lib/action_dispatch/testing/integration.rb:297:in 'ActionDispatch::Integration::Session#process' lib/action_dispatch/testing/integration.rb:49:in 'ActionDispatch::Integration::RequestHelpers#head' lib/action_dispatch/testing/integration.rb:388:in 'ActionDispatch::Integration::Runner#head' test/dispatch/show_exceptions_test.rb:73:in 'block in ' ``` --- actionpack/CHANGELOG.md | 7 +++++ .../middleware/debug_exceptions.rb | 4 ++- .../middleware/public_exceptions.rb | 6 ++++- .../test/dispatch/debug_exceptions_test.rb | 9 +++++++ .../test/dispatch/show_exceptions_test.rb | 26 +++++++++++++++++++ 5 files changed, 50 insertions(+), 2 deletions(-) diff --git a/actionpack/CHANGELOG.md b/actionpack/CHANGELOG.md index de6b30e25f89a..708812d618810 100644 --- a/actionpack/CHANGELOG.md +++ b/actionpack/CHANGELOG.md @@ -1,3 +1,10 @@ +* Always return empty body for HEAD requests in `PublicExceptions` and + `DebugExceptions`. + + This is required by `Rack::Lint` (per RFC9110). + + *Hartley McGuire* + * Add comprehensive support for HTTP Cache-Control request directives according to RFC 9111. Provides a `request.cache_control_directives` object that gives access to request cache directives: diff --git a/actionpack/lib/action_dispatch/middleware/debug_exceptions.rb b/actionpack/lib/action_dispatch/middleware/debug_exceptions.rb index baa7e3d27ca90..8f87907c7bffd 100644 --- a/actionpack/lib/action_dispatch/middleware/debug_exceptions.rb +++ b/actionpack/lib/action_dispatch/middleware/debug_exceptions.rb @@ -65,7 +65,9 @@ def render_exception(request, exception, wrapper) content_type = Mime[:text] end - if api_request?(content_type) + if request.head? + render(wrapper.status_code, "", content_type) + elsif api_request?(content_type) render_for_api_request(content_type, wrapper) else render_for_browser_request(request, wrapper) diff --git a/actionpack/lib/action_dispatch/middleware/public_exceptions.rb b/actionpack/lib/action_dispatch/middleware/public_exceptions.rb index 6e5c872fd1e6b..51a5b13d0d018 100644 --- a/actionpack/lib/action_dispatch/middleware/public_exceptions.rb +++ b/actionpack/lib/action_dispatch/middleware/public_exceptions.rb @@ -28,7 +28,11 @@ def call(env) content_type = request.formats.first body = { status: status, error: Rack::Utils::HTTP_STATUS_CODES.fetch(status, Rack::Utils::HTTP_STATUS_CODES[500]) } - render(status, content_type, body) + if env["action_dispatch.original_request_method"] == "HEAD" + render_format(status, content_type, "") + else + render(status, content_type, body) + end end private diff --git a/actionpack/test/dispatch/debug_exceptions_test.rb b/actionpack/test/dispatch/debug_exceptions_test.rb index fb9282b141361..6c4e0b308536d 100644 --- a/actionpack/test/dispatch/debug_exceptions_test.rb +++ b/actionpack/test/dispatch/debug_exceptions_test.rb @@ -183,6 +183,15 @@ def self.build_app(app, *args) assert boomer.closed, "Expected to close the response body" end + test "returns empty body on HEAD cascade pass" do + @app = DevelopmentApp + + head "/pass" + + assert_response 404 + assert_equal "", body + end + test "displays routes in a table when a RoutingError occurs" do @app = DevelopmentApp get "/pass", headers: { "action_dispatch.show_exceptions" => :all } diff --git a/actionpack/test/dispatch/show_exceptions_test.rb b/actionpack/test/dispatch/show_exceptions_test.rb index 7115f6a93a8d9..1cb70be82b7fc 100644 --- a/actionpack/test/dispatch/show_exceptions_test.rb +++ b/actionpack/test/dispatch/show_exceptions_test.rb @@ -69,6 +69,32 @@ def setup assert_equal "", body end + test "rescue with no body for HEAD requests" do + head "/", env: { "action_dispatch.show_exceptions" => :all } + assert_response 500 + assert_equal "", body + + head "/bad_params", env: { "action_dispatch.show_exceptions" => :all } + assert_response 400 + assert_equal "", body + + head "/not_found", env: { "action_dispatch.show_exceptions" => :all } + assert_response 404 + assert_equal "", body + + head "/method_not_allowed", env: { "action_dispatch.show_exceptions" => :all } + assert_response 405 + assert_equal "", body + + head "/unknown_http_method", env: { "action_dispatch.show_exceptions" => :all } + assert_response 405 + assert_equal "", body + + head "/invalid_mimetype", headers: { "Accept" => "text/html,*", "action_dispatch.show_exceptions" => :all } + assert_response 406 + assert_equal "", body + end + test "localize rescue error page" do old_locale, I18n.locale = I18n.locale, :da From 606ba30ad4b7663ea5c171cf688cfcddd995b2d7 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Wed, 4 Jun 2025 15:08:25 -0700 Subject: [PATCH 0212/1075] Upload progress accounts for server processing time (#55157) Progress bars now reflect the full upload lifecycle: 90% for request transmission, 10% for server processing and response. Prevents progress bars from completing prematurely while the server is still processing buffered uploads, giving the impression that something has failed or stalled. Progress is reported in two phases: - 0-90%: Request transmission. Actual upload progress. - 90-100%: Server processing time, estimated based on file size: - 1s for <1MB; 2s for 1-10MB; 3s+ for larger files The 90/10 split reflects that most direct uploads are unbuffered and complete quickly after transmission. Even buffered uploads typically process faster than their upload time. No app changes required for apps using Active Storage or Action Text. Progress events dispatch with the same `{ progress: number }` format. Fixes #42228 --- actiontext/CHANGELOG.md | 4 + .../app/assets/javascripts/actiontext.esm.js | 78 +++++- .../app/assets/javascripts/actiontext.js | 78 +++++- .../actiontext/attachment_upload.js | 51 +++- activestorage/CHANGELOG.md | 4 + .../assets/javascripts/activestorage.esm.js | 38 ++- .../app/assets/javascripts/activestorage.js | 38 ++- .../activestorage/direct_upload_controller.js | 49 +++- yarn.lock | 235 +++++++----------- 9 files changed, 424 insertions(+), 151 deletions(-) diff --git a/actiontext/CHANGELOG.md b/actiontext/CHANGELOG.md index 9f1ff1b275c85..bb87b10aea1bc 100644 --- a/actiontext/CHANGELOG.md +++ b/actiontext/CHANGELOG.md @@ -1,3 +1,7 @@ +* Attachment upload progress accounts for server processing time. + + *Jeremy Daer* + * The Trix dependency is now satisfied by a gem, `action_text-trix`, rather than vendored files. This allows applications to bump Trix versions independently of Rails releases. Effectively this also upgrades Trix to `>= 2.1.15`. diff --git a/actiontext/app/assets/javascripts/actiontext.esm.js b/actiontext/app/assets/javascripts/actiontext.esm.js index 79028350380a3..6ee8fd8eda73b 100644 --- a/actiontext/app/assets/javascripts/actiontext.esm.js +++ b/actiontext/app/assets/javascripts/actiontext.esm.js @@ -672,7 +672,7 @@ class DirectUploadController { })); } uploadRequestDidProgress(event) { - const progress = event.loaded / event.total * 100; + const progress = event.loaded / event.total * 90; if (progress) { this.dispatch("progress", { progress: progress @@ -707,6 +707,42 @@ class DirectUploadController { xhr: xhr }); xhr.upload.addEventListener("progress", (event => this.uploadRequestDidProgress(event))); + xhr.upload.addEventListener("loadend", (() => { + this.simulateResponseProgress(xhr); + })); + } + simulateResponseProgress(xhr) { + let progress = 90; + const startTime = Date.now(); + const updateProgress = () => { + const elapsed = Date.now() - startTime; + const estimatedResponseTime = this.estimateResponseTime(); + const responseProgress = Math.min(elapsed / estimatedResponseTime, 1); + progress = 90 + responseProgress * 9; + this.dispatch("progress", { + progress: progress + }); + if (xhr.readyState !== XMLHttpRequest.DONE && progress < 99) { + requestAnimationFrame(updateProgress); + } + }; + xhr.addEventListener("loadend", (() => { + this.dispatch("progress", { + progress: 100 + }); + })); + requestAnimationFrame(updateProgress); + } + estimateResponseTime() { + const fileSize = this.file.size; + const MB = 1024 * 1024; + if (fileSize < MB) { + return 1e3; + } else if (fileSize < 10 * MB) { + return 2e3; + } else { + return 3e3 + fileSize / MB * 50; + } } } @@ -857,7 +893,7 @@ class AttachmentUpload { } directUploadWillStoreFileWithXHR(xhr) { xhr.upload.addEventListener("progress", (event => { - const progress = event.loaded / event.total * 100; + const progress = event.loaded / event.total * 90; this.attachment.setUploadProgress(progress); if (progress) { this.dispatch("progress", { @@ -865,6 +901,44 @@ class AttachmentUpload { }); } })); + xhr.upload.addEventListener("loadend", (() => { + this.simulateResponseProgress(xhr); + })); + } + simulateResponseProgress(xhr) { + let progress = 90; + const startTime = Date.now(); + const updateProgress = () => { + const elapsed = Date.now() - startTime; + const estimatedResponseTime = this.estimateResponseTime(); + const responseProgress = Math.min(elapsed / estimatedResponseTime, 1); + progress = 90 + responseProgress * 9; + this.attachment.setUploadProgress(progress); + this.dispatch("progress", { + progress: progress + }); + if (xhr.readyState !== XMLHttpRequest.DONE && progress < 99) { + requestAnimationFrame(updateProgress); + } + }; + xhr.addEventListener("loadend", (() => { + this.attachment.setUploadProgress(100); + this.dispatch("progress", { + progress: 100 + }); + })); + requestAnimationFrame(updateProgress); + } + estimateResponseTime() { + const fileSize = this.attachment.file.size; + const MB = 1024 * 1024; + if (fileSize < MB) { + return 1e3; + } else if (fileSize < 10 * MB) { + return 2e3; + } else { + return 3e3 + fileSize / MB * 50; + } } directUploadDidComplete(error, attributes) { if (error) { diff --git a/actiontext/app/assets/javascripts/actiontext.js b/actiontext/app/assets/javascripts/actiontext.js index e61f03ce103fa..46b8a873c72ff 100644 --- a/actiontext/app/assets/javascripts/actiontext.js +++ b/actiontext/app/assets/javascripts/actiontext.js @@ -661,7 +661,7 @@ })); } uploadRequestDidProgress(event) { - const progress = event.loaded / event.total * 100; + const progress = event.loaded / event.total * 90; if (progress) { this.dispatch("progress", { progress: progress @@ -696,6 +696,42 @@ xhr: xhr }); xhr.upload.addEventListener("progress", (event => this.uploadRequestDidProgress(event))); + xhr.upload.addEventListener("loadend", (() => { + this.simulateResponseProgress(xhr); + })); + } + simulateResponseProgress(xhr) { + let progress = 90; + const startTime = Date.now(); + const updateProgress = () => { + const elapsed = Date.now() - startTime; + const estimatedResponseTime = this.estimateResponseTime(); + const responseProgress = Math.min(elapsed / estimatedResponseTime, 1); + progress = 90 + responseProgress * 9; + this.dispatch("progress", { + progress: progress + }); + if (xhr.readyState !== XMLHttpRequest.DONE && progress < 99) { + requestAnimationFrame(updateProgress); + } + }; + xhr.addEventListener("loadend", (() => { + this.dispatch("progress", { + progress: 100 + }); + })); + requestAnimationFrame(updateProgress); + } + estimateResponseTime() { + const fileSize = this.file.size; + const MB = 1024 * 1024; + if (fileSize < MB) { + return 1e3; + } else if (fileSize < 10 * MB) { + return 2e3; + } else { + return 3e3 + fileSize / MB * 50; + } } } const inputSelector = "input[type=file][data-direct-upload-url]:not([disabled])"; @@ -830,7 +866,7 @@ } directUploadWillStoreFileWithXHR(xhr) { xhr.upload.addEventListener("progress", (event => { - const progress = event.loaded / event.total * 100; + const progress = event.loaded / event.total * 90; this.attachment.setUploadProgress(progress); if (progress) { this.dispatch("progress", { @@ -838,6 +874,44 @@ }); } })); + xhr.upload.addEventListener("loadend", (() => { + this.simulateResponseProgress(xhr); + })); + } + simulateResponseProgress(xhr) { + let progress = 90; + const startTime = Date.now(); + const updateProgress = () => { + const elapsed = Date.now() - startTime; + const estimatedResponseTime = this.estimateResponseTime(); + const responseProgress = Math.min(elapsed / estimatedResponseTime, 1); + progress = 90 + responseProgress * 9; + this.attachment.setUploadProgress(progress); + this.dispatch("progress", { + progress: progress + }); + if (xhr.readyState !== XMLHttpRequest.DONE && progress < 99) { + requestAnimationFrame(updateProgress); + } + }; + xhr.addEventListener("loadend", (() => { + this.attachment.setUploadProgress(100); + this.dispatch("progress", { + progress: 100 + }); + })); + requestAnimationFrame(updateProgress); + } + estimateResponseTime() { + const fileSize = this.attachment.file.size; + const MB = 1024 * 1024; + if (fileSize < MB) { + return 1e3; + } else if (fileSize < 10 * MB) { + return 2e3; + } else { + return 3e3 + fileSize / MB * 50; + } } directUploadDidComplete(error, attributes) { if (error) { diff --git a/actiontext/app/javascript/actiontext/attachment_upload.js b/actiontext/app/javascript/actiontext/attachment_upload.js index d2c0a3af2e15c..354fa35a0dba6 100644 --- a/actiontext/app/javascript/actiontext/attachment_upload.js +++ b/actiontext/app/javascript/actiontext/attachment_upload.js @@ -14,12 +14,61 @@ export class AttachmentUpload { directUploadWillStoreFileWithXHR(xhr) { xhr.upload.addEventListener("progress", event => { - const progress = event.loaded / event.total * 100 + // Scale upload progress to 0-90% range + const progress = (event.loaded / event.total) * 90 this.attachment.setUploadProgress(progress) if (progress) { this.dispatch("progress", { progress: progress }) } }) + + // Start simulating progress after upload completes + xhr.upload.addEventListener("loadend", () => { + this.simulateResponseProgress(xhr) + }) + } + + simulateResponseProgress(xhr) { + let progress = 90 + const startTime = Date.now() + + const updateProgress = () => { + // Simulate progress from 90% to 99% over estimated time + const elapsed = Date.now() - startTime + const estimatedResponseTime = this.estimateResponseTime() + const responseProgress = Math.min(elapsed / estimatedResponseTime, 1) + progress = 90 + (responseProgress * 9) // 90% to 99% + + this.attachment.setUploadProgress(progress) + this.dispatch("progress", { progress }) + + // Continue until response arrives or we hit 99% + if (xhr.readyState !== XMLHttpRequest.DONE && progress < 99) { + requestAnimationFrame(updateProgress) + } + } + + // Stop simulation when response arrives + xhr.addEventListener("loadend", () => { + this.attachment.setUploadProgress(100) + this.dispatch("progress", { progress: 100 }) + }) + + requestAnimationFrame(updateProgress) + } + + estimateResponseTime() { + // Base estimate: 1 second for small files, scaling up for larger files + const fileSize = this.attachment.file.size + const MB = 1024 * 1024 + + if (fileSize < MB) { + return 1000 // 1 second for files under 1MB + } else if (fileSize < 10 * MB) { + return 2000 // 2 seconds for files 1-10MB + } else { + return 3000 + (fileSize / MB * 50) // 3+ seconds for larger files + } } directUploadDidComplete(error, attributes) { diff --git a/activestorage/CHANGELOG.md b/activestorage/CHANGELOG.md index 2f6169c2b114d..ddf6bad1f2657 100644 --- a/activestorage/CHANGELOG.md +++ b/activestorage/CHANGELOG.md @@ -1,3 +1,7 @@ +* Direct upload progress accounts for server processing time. + + *Jeremy Daer* + * Delegate `ActiveStorage::Filename#to_str` to `#to_s` Supports checking String equality: diff --git a/activestorage/app/assets/javascripts/activestorage.esm.js b/activestorage/app/assets/javascripts/activestorage.esm.js index 1ac98355eccb5..c0ec12f4f15fd 100644 --- a/activestorage/app/assets/javascripts/activestorage.esm.js +++ b/activestorage/app/assets/javascripts/activestorage.esm.js @@ -672,7 +672,7 @@ class DirectUploadController { })); } uploadRequestDidProgress(event) { - const progress = event.loaded / event.total * 100; + const progress = event.loaded / event.total * 90; if (progress) { this.dispatch("progress", { progress: progress @@ -707,6 +707,42 @@ class DirectUploadController { xhr: xhr }); xhr.upload.addEventListener("progress", (event => this.uploadRequestDidProgress(event))); + xhr.upload.addEventListener("loadend", (() => { + this.simulateResponseProgress(xhr); + })); + } + simulateResponseProgress(xhr) { + let progress = 90; + const startTime = Date.now(); + const updateProgress = () => { + const elapsed = Date.now() - startTime; + const estimatedResponseTime = this.estimateResponseTime(); + const responseProgress = Math.min(elapsed / estimatedResponseTime, 1); + progress = 90 + responseProgress * 9; + this.dispatch("progress", { + progress: progress + }); + if (xhr.readyState !== XMLHttpRequest.DONE && progress < 99) { + requestAnimationFrame(updateProgress); + } + }; + xhr.addEventListener("loadend", (() => { + this.dispatch("progress", { + progress: 100 + }); + })); + requestAnimationFrame(updateProgress); + } + estimateResponseTime() { + const fileSize = this.file.size; + const MB = 1024 * 1024; + if (fileSize < MB) { + return 1e3; + } else if (fileSize < 10 * MB) { + return 2e3; + } else { + return 3e3 + fileSize / MB * 50; + } } } diff --git a/activestorage/app/assets/javascripts/activestorage.js b/activestorage/app/assets/javascripts/activestorage.js index 754c727bc8970..16526b87af44b 100644 --- a/activestorage/app/assets/javascripts/activestorage.js +++ b/activestorage/app/assets/javascripts/activestorage.js @@ -662,7 +662,7 @@ })); } uploadRequestDidProgress(event) { - const progress = event.loaded / event.total * 100; + const progress = event.loaded / event.total * 90; if (progress) { this.dispatch("progress", { progress: progress @@ -697,6 +697,42 @@ xhr: xhr }); xhr.upload.addEventListener("progress", (event => this.uploadRequestDidProgress(event))); + xhr.upload.addEventListener("loadend", (() => { + this.simulateResponseProgress(xhr); + })); + } + simulateResponseProgress(xhr) { + let progress = 90; + const startTime = Date.now(); + const updateProgress = () => { + const elapsed = Date.now() - startTime; + const estimatedResponseTime = this.estimateResponseTime(); + const responseProgress = Math.min(elapsed / estimatedResponseTime, 1); + progress = 90 + responseProgress * 9; + this.dispatch("progress", { + progress: progress + }); + if (xhr.readyState !== XMLHttpRequest.DONE && progress < 99) { + requestAnimationFrame(updateProgress); + } + }; + xhr.addEventListener("loadend", (() => { + this.dispatch("progress", { + progress: 100 + }); + })); + requestAnimationFrame(updateProgress); + } + estimateResponseTime() { + const fileSize = this.file.size; + const MB = 1024 * 1024; + if (fileSize < MB) { + return 1e3; + } else if (fileSize < 10 * MB) { + return 2e3; + } else { + return 3e3 + fileSize / MB * 50; + } } } const inputSelector = "input[type=file][data-direct-upload-url]:not([disabled])"; diff --git a/activestorage/app/javascript/activestorage/direct_upload_controller.js b/activestorage/app/javascript/activestorage/direct_upload_controller.js index 987050889a750..25daa84090a4c 100644 --- a/activestorage/app/javascript/activestorage/direct_upload_controller.js +++ b/activestorage/app/javascript/activestorage/direct_upload_controller.js @@ -31,7 +31,8 @@ export class DirectUploadController { } uploadRequestDidProgress(event) { - const progress = event.loaded / event.total * 100 + // Scale upload progress to 0-90% range + const progress = (event.loaded / event.total) * 90 if (progress) { this.dispatch("progress", { progress }) } @@ -63,5 +64,51 @@ export class DirectUploadController { directUploadWillStoreFileWithXHR(xhr) { this.dispatch("before-storage-request", { xhr }) xhr.upload.addEventListener("progress", event => this.uploadRequestDidProgress(event)) + + // Start simulating progress after upload completes + xhr.upload.addEventListener("loadend", () => { + this.simulateResponseProgress(xhr) + }) + } + + simulateResponseProgress(xhr) { + let progress = 90 + const startTime = Date.now() + + const updateProgress = () => { + // Simulate progress from 90% to 99% over estimated time + const elapsed = Date.now() - startTime + const estimatedResponseTime = this.estimateResponseTime() + const responseProgress = Math.min(elapsed / estimatedResponseTime, 1) + progress = 90 + (responseProgress * 9) // 90% to 99% + + this.dispatch("progress", { progress }) + + // Continue until response arrives or we hit 99% + if (xhr.readyState !== XMLHttpRequest.DONE && progress < 99) { + requestAnimationFrame(updateProgress) + } + } + + // Stop simulation when response arrives + xhr.addEventListener("loadend", () => { + this.dispatch("progress", { progress: 100 }) + }) + + requestAnimationFrame(updateProgress) + } + + estimateResponseTime() { + // Base estimate: 1 second for small files, scaling up for larger files + const fileSize = this.file.size + const MB = 1024 * 1024 + + if (fileSize < MB) { + return 1000 // 1 second for files under 1MB + } else if (fileSize < 10 * MB) { + return 2000 // 2 seconds for files 1-10MB + } else { + return 3000 + (fileSize / MB * 50) // 3+ seconds for larger files + } } } diff --git a/yarn.lock b/yarn.lock index 4acc6ca750532..80cdfa44e449c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -142,29 +142,6 @@ "@nodelib/fs.scandir" "2.1.5" fastq "^1.6.0" -"@rails/actioncable@file:/workspaces/rails/actioncable": - version "8.1.0-alpha" - resolved "file:actioncable" - -"@rails/actiontext@file:/workspaces/rails/actiontext": - version "8.1.0-alpha" - resolved "file:actiontext" - dependencies: - "@rails/activestorage" ">= 8.0.0-alpha" - -"@rails/activestorage@>= 8.0.0-alpha": - version "8.0.0-rc1" - resolved "https://registry.npmjs.org/@rails/activestorage/-/activestorage-8.0.0-rc1.tgz" - integrity sha512-gI2Ij7mDN3d/MCPebE67hL30Y9I5reGvMWGOhFzvz0JMnf7n5U+tm1/B68Vm801g/vsbeLM+RtkuDSl+OZee9w== - dependencies: - spark-md5 "^3.0.1" - -"@rails/activestorage@file:/workspaces/rails/activestorage": - version "8.1.0-alpha" - resolved "file:activestorage" - dependencies: - spark-md5 "^3.0.1" - "@rollup/plugin-commonjs@^19.0.1": version "19.0.2" resolved "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-19.0.2.tgz" @@ -258,7 +235,7 @@ acorn-jsx@^5.3.2: resolved "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz" integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== -"acorn@^6.0.0 || ^7.0.0 || ^8.0.0", acorn@^8.8.2, acorn@^8.9.0: +acorn@^8.8.2, acorn@^8.9.0: version "8.12.1" resolved "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz" integrity sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg== @@ -268,13 +245,6 @@ adm-zip@~0.4.3: resolved "https://registry.npmjs.org/adm-zip/-/adm-zip-0.4.16.tgz" integrity sha512-TFi4HBKSGfIKsK5YCkKaaFG2m4PEDyViZmEwof3MTIgzimHLto6muaHVpbrljdIvIrFZzEq/p4nafOeLcYegrg== -agent-base@^4.3.0: - version "4.3.0" - resolved "https://registry.npmjs.org/agent-base/-/agent-base-4.3.0.tgz" - integrity sha512-salcGninV0nPrwpGNn4VTXBb1SOuXQBiqbrNXoeizJsHrsL6ERFM2Ne3JUSBWRE6aeNJI2ROP/WEEIDUiDe3cg== - dependencies: - es6-promisify "^5.0.0" - agent-base@6: version "6.0.2" resolved "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz" @@ -282,6 +252,13 @@ agent-base@6: dependencies: debug "4" +agent-base@^4.3.0: + version "4.3.0" + resolved "https://registry.npmjs.org/agent-base/-/agent-base-4.3.0.tgz" + integrity sha512-salcGninV0nPrwpGNn4VTXBb1SOuXQBiqbrNXoeizJsHrsL6ERFM2Ne3JUSBWRE6aeNJI2ROP/WEEIDUiDe3cg== + dependencies: + es6-promisify "^5.0.0" + ajv@^6.12.3, ajv@^6.12.4: version "6.12.6" resolved "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz" @@ -424,7 +401,7 @@ asn1@~0.2.3: dependencies: safer-buffer "~2.1.0" -assert-plus@^1.0.0, assert-plus@1.0.0: +assert-plus@1.0.0, assert-plus@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz" integrity sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw== @@ -468,7 +445,7 @@ base64-js@^1.3.1: resolved "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz" integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== -base64id@~2.0.0, base64id@2.0.0: +base64id@2.0.0, base64id@~2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz" integrity sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog== @@ -631,16 +608,16 @@ color-convert@^2.0.1: dependencies: color-name "~1.1.4" -color-name@~1.1.4: - version "1.1.4" - resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz" - integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== - color-name@1.1.3: version "1.1.3" resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz" integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw== +color-name@~1.1.4: + version "1.1.4" + resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz" + integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== + combined-stream@^1.0.6, combined-stream@~1.0.6: version "1.0.8" resolved "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz" @@ -648,16 +625,16 @@ combined-stream@^1.0.6, combined-stream@~1.0.6: dependencies: delayed-stream "~1.0.0" -commander@^2.20.0: - version "2.20.3" - resolved "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz" - integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== - commander@7.2.0: version "7.2.0" resolved "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz" integrity sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw== +commander@^2.20.0: + version "2.20.3" + resolved "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz" + integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== + commondir@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz" @@ -698,16 +675,16 @@ cookie@~0.7.2: resolved "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz" integrity sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w== -core-util-is@~1.0.0: - version "1.0.3" - resolved "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz" - integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ== - core-util-is@1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz" integrity sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ== +core-util-is@~1.0.0: + version "1.0.3" + resolved "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz" + integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ== + cors@~2.8.5: version "2.8.5" resolved "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz" @@ -716,13 +693,6 @@ cors@~2.8.5: object-assign "^4" vary "^1" -crc@^3.4.4: - version "3.8.0" - resolved "https://registry.npmjs.org/crc/-/crc-3.8.0.tgz" - integrity sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ== - dependencies: - buffer "^5.1.0" - crc32-stream@^3.0.1: version "3.0.1" resolved "https://registry.npmjs.org/crc32-stream/-/crc32-stream-3.0.1.tgz" @@ -731,6 +701,13 @@ crc32-stream@^3.0.1: crc "^3.4.4" readable-stream "^3.4.0" +crc@^3.4.4: + version "3.8.0" + resolved "https://registry.npmjs.org/crc/-/crc-3.8.0.tgz" + integrity sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ== + dependencies: + buffer "^5.1.0" + cross-spawn@^7.0.2: version "7.0.3" resolved "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz" @@ -784,33 +761,26 @@ date-format@^4.0.14: resolved "https://registry.npmjs.org/date-format/-/date-format-4.0.14.tgz" integrity sha512-39BOQLs9ZjKh0/patS9nrT8wc3ioX3/eA/zgbKNopnF2wCqJEoxywwwElATYvRsXdnOxA/OQeQoFZ3rFjVajhg== -debug@^3.1.0: - version "3.2.7" - resolved "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz" - integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ== - dependencies: - ms "^2.1.1" - -debug@^3.2.7: - version "3.2.7" - resolved "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz" - integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ== +debug@2.6.9: + version "2.6.9" + resolved "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz" + integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== dependencies: - ms "^2.1.1" + ms "2.0.0" -debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@~4.3.1, debug@~4.3.2, debug@~4.3.4, debug@4: +debug@4, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@~4.3.1, debug@~4.3.2, debug@~4.3.4: version "4.3.4" resolved "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz" integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== dependencies: ms "2.1.2" -debug@2.6.9: - version "2.6.9" - resolved "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz" - integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== +debug@^3.1.0, debug@^3.2.7: + version "3.2.7" + resolved "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz" + integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ== dependencies: - ms "2.0.0" + ms "^2.1.1" deep-is@^0.1.3: version "0.1.4" @@ -1128,7 +1098,7 @@ eslint-visitor-keys@^3.3.0, eslint-visitor-keys@^3.4.1, eslint-visitor-keys@^3.4 resolved "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz" integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag== -"eslint@^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8", eslint@^2.13.1, "eslint@^6.0.0 || ^7.0.0 || >=8.0.0", eslint@^8.40.0: +eslint@^8.40.0: version "8.57.1" resolved "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz" integrity sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA== @@ -1225,7 +1195,7 @@ extend@^3.0.0, extend@~3.0.2: resolved "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz" integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== -extsprintf@^1.2.0, extsprintf@1.3.0: +extsprintf@1.3.0, extsprintf@^1.2.0: version "1.3.0" resolved "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz" integrity sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g== @@ -1352,6 +1322,11 @@ fs.realpath@^1.0.0: resolved "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz" integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== +fsevents@~2.3.2: + version "2.3.3" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" + integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== + function-bind@^1.1.1, function-bind@^1.1.2: version "1.1.2" resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz" @@ -1627,7 +1602,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.3, inherits@2, inherits@2.0.4: +inherits@2, inherits@2.0.4, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.3: version "2.0.4" resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -2072,11 +2047,6 @@ mock-socket@^2.0.0: dependencies: urijs "~1.17.0" -ms@^2.1.1: - version "2.1.3" - resolved "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz" - integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== - ms@2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz" @@ -2087,6 +2057,11 @@ ms@2.1.2: resolved "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== +ms@^2.1.1: + version "2.1.3" + resolved "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + natural-compare@^1.4.0: version "1.4.0" resolved "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz" @@ -2170,13 +2145,6 @@ object.values@^1.1.7: define-properties "^1.2.0" es-abstract "^1.22.1" -on-finished@~2.3.0: - version "2.3.0" - resolved "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz" - integrity sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww== - dependencies: - ee-first "1.1.1" - on-finished@2.4.1: version "2.4.1" resolved "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz" @@ -2184,6 +2152,13 @@ on-finished@2.4.1: dependencies: ee-first "1.1.1" +on-finished@~2.3.0: + version "2.3.0" + resolved "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz" + integrity sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww== + dependencies: + ee-first "1.1.1" + once@^1.3.0, once@^1.4.0: version "1.4.0" resolved "https://registry.npmjs.org/once/-/once-1.4.0.tgz" @@ -2304,11 +2279,6 @@ qjobs@^1.2.0: resolved "https://registry.npmjs.org/qjobs/-/qjobs-1.2.0.tgz" integrity sha512-8YOJEHtxpySA3fFDyCRxA+UUV+fA+rTWnuWvylOK/NCjhY+b4ocCtmu8TtsWb+mYeU+GCHf/S66KZF/AsteKHg== -qs@~6.5.2: - version "6.5.3" - resolved "https://registry.npmjs.org/qs/-/qs-6.5.3.tgz" - integrity sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA== - qs@6.11.0: version "6.11.0" resolved "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz" @@ -2316,12 +2286,17 @@ qs@6.11.0: dependencies: side-channel "^1.0.4" +qs@~6.5.2: + version "6.5.3" + resolved "https://registry.npmjs.org/qs/-/qs-6.5.3.tgz" + integrity sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA== + queue-microtask@^1.2.2: version "1.2.3" resolved "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz" integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== -qunit@^2.0.0, qunit@^2.8.0: +qunit@^2.8.0: version "2.19.4" resolved "https://registry.npmjs.org/qunit/-/qunit-2.19.4.tgz" integrity sha512-aqUzzUeCqlleWYKlpgfdHHw9C6KxkB9H3wNfiBg5yHqQMzy0xw/pbCRHYFkjl8MsP/t8qkTQE+JTYL71azgiew== @@ -2352,33 +2327,7 @@ raw-body@2.5.2: iconv-lite "0.4.24" unpipe "1.0.0" -readable-stream@^2.0.0: - version "2.3.8" - resolved "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz" - integrity sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA== - dependencies: - core-util-is "~1.0.0" - inherits "~2.0.3" - isarray "~1.0.0" - process-nextick-args "~2.0.0" - safe-buffer "~5.1.1" - string_decoder "~1.1.1" - util-deprecate "~1.0.1" - -readable-stream@^2.0.5: - version "2.3.8" - resolved "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz" - integrity sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA== - dependencies: - core-util-is "~1.0.0" - inherits "~2.0.3" - isarray "~1.0.0" - process-nextick-args "~2.0.0" - safe-buffer "~5.1.1" - string_decoder "~1.1.1" - util-deprecate "~1.0.1" - -readable-stream@^2.3.6: +readable-stream@^2.0.0, readable-stream@^2.0.5, readable-stream@^2.3.6: version "2.3.8" resolved "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz" integrity sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA== @@ -2510,7 +2459,7 @@ rollup-plugin-terser@^7.0.2: serialize-javascript "^4.0.0" terser "^5.0.0" -rollup@^1.20.0||^2.0.0, rollup@^2.0.0, rollup@^2.35.1, rollup@^2.38.3: +rollup@^2.35.1: version "2.79.1" resolved "https://registry.npmjs.org/rollup/-/rollup-2.79.1.tgz" integrity sha512-uKxbd0IhMZOhjAiD5oAFp7BqvkA4Dv47qpOCtaNvng4HBwdbWtdOh8f5nZNuk2rp51PMGk3bzfWu5oayNEuYnw== @@ -2553,7 +2502,7 @@ safe-regex-test@^1.0.3: es-errors "^1.3.0" is-regex "^1.1.4" -safer-buffer@^2.0.2, safer-buffer@^2.1.0, "safer-buffer@>= 2.1.2 < 3", safer-buffer@~2.1.0: +"safer-buffer@>= 2.1.2 < 3", safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0: version "2.1.2" resolved "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== @@ -2704,16 +2653,16 @@ sshpk@^1.7.0: safer-buffer "^2.0.2" tweetnacl "~0.14.0" -statuses@~1.5.0: - version "1.5.0" - resolved "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz" - integrity sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA== - statuses@2.0.1: version "2.0.1" resolved "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz" integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ== +statuses@~1.5.0: + version "1.5.0" + resolved "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz" + integrity sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA== + streamroller@^3.1.5: version "3.1.5" resolved "https://registry.npmjs.org/streamroller/-/streamroller-3.1.5.tgz" @@ -2723,20 +2672,6 @@ streamroller@^3.1.5: debug "^4.3.4" fs-extra "^8.1.0" -string_decoder@^1.1.1: - version "1.3.0" - resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz" - integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== - dependencies: - safe-buffer "~5.2.0" - -string_decoder@~1.1.1: - version "1.1.1" - resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz" - integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg== - dependencies: - safe-buffer "~5.1.0" - string-width@^4.1.0, string-width@^4.2.0: version "4.2.3" resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" @@ -2774,6 +2709,20 @@ string.prototype.trimstart@^1.0.8: define-properties "^1.2.1" es-object-atoms "^1.0.0" +string_decoder@^1.1.1: + version "1.3.0" + resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz" + integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== + dependencies: + safe-buffer "~5.2.0" + +string_decoder@~1.1.1: + version "1.1.1" + resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz" + integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg== + dependencies: + safe-buffer "~5.1.0" + strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" @@ -2985,7 +2934,7 @@ universalify@^0.1.0: resolved "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz" integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg== -unpipe@~1.0.0, unpipe@1.0.0: +unpipe@1.0.0, unpipe@~1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz" integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ== From 5d43fcc3ee1535f94f8995dba501b89a75faac9e Mon Sep 17 00:00:00 2001 From: Edouard CHIN Date: Fri, 6 Jun 2025 03:49:15 +0200 Subject: [PATCH 0213/1075] Prevent Action View digestor from tracking random "render": - Fix #55161 - ### Problem Caching a partial that contains the word "render" (literally, not the `render` method), would create a false positive in AV's digestor. This results in a rather confusing log message. ```html+erb <%# _partial.html.erb %> <% cache "key" do %>
<% end %> ``` The log error: "Couldn't find template for digesting: flexes/flex" ### Solution Tweak the regex and check for surrounding erb tags. --- .../dependency_tracker/erb_tracker.rb | 2 +- .../test/template/dependency_tracker_test.rb | 23 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/actionview/lib/action_view/dependency_tracker/erb_tracker.rb b/actionview/lib/action_view/dependency_tracker/erb_tracker.rb index d805fcc521cdc..2432ad99b3062 100644 --- a/actionview/lib/action_view/dependency_tracker/erb_tracker.rb +++ b/actionview/lib/action_view/dependency_tracker/erb_tracker.rb @@ -91,7 +91,7 @@ def directory def render_dependencies dependencies = [] - render_calls = source.split(/\brender\b/).drop(1) + render_calls = source.scan(/<%(?:(?:(?!<%).)*?\brender\b((?:(?!%>).)*?))%>/m).flatten render_calls.each do |arguments| add_dependencies(dependencies, arguments, LAYOUT_DEPENDENCY) diff --git a/actionview/test/template/dependency_tracker_test.rb b/actionview/test/template/dependency_tracker_test.rb index b1f611e1c2b48..e0ea0ca632224 100644 --- a/actionview/test/template/dependency_tracker_test.rb +++ b/actionview/test/template/dependency_tracker_test.rb @@ -118,6 +118,29 @@ def test_finds_no_dependency_when_render_ends_the_name_of_another_method assert_equal [], tracker.dependencies end + def test_finds_no_dependency_when_render_is_not_a_ruby_call + template = FakeTemplate.new("
", :erb) + tracker = make_tracker("resources/_resource", template) + + assert_equal [], tracker.dependencies + end + + def test_find_dependencies_and_respect_erb_tag_boundaries + template = FakeTemplate.new("

Hello

<% link_to abc %> <%= render 'single/quote' %>", :erb) + tracker = make_tracker("resources/_resource", template) + + assert_equal ["single/quote"], tracker.dependencies + end + + def test_find_all_dependencies_and_respect_erb_tag_boundaries + template = FakeTemplate.new("

Hello

<%= + render object: @all_posts, + partial: 'posts' %> <% link_to abc %> <%= render 'single/quote' %>", :erb) + tracker = make_tracker("resources/_resource", template) + + assert_equal ["resources/posts", "single/quote"], tracker.dependencies + end + def test_finds_dependency_on_multiline_render_calls template = FakeTemplate.new("<%= render object: @all_posts, From e9626a3e16c23fa37c0433ba3c30ccda19d30ddf Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Tue, 10 Jun 2025 17:51:17 +0100 Subject: [PATCH 0214/1075] Active Job Continuations continued (#55174) Follow up to #55127 and #55151 - Instrument steps in a block so that runtime is recorded (matching perform/perform_started) - Allow job resumption configuration - max_resumptions, resume_options, resume_errors_after_advancing - Add Active Record Railtie to prevent checkpoints in database transactions - Checkpoint before each step except the first, rather than after each step. - Error if order of completed steps changes when re-running --- activejob/lib/active_job/continuable.rb | 68 +++++++-- activejob/lib/active_job/continuation.rb | 124 +++++++-------- activejob/lib/active_job/continuation/step.rb | 8 +- .../active_job/continuation/test_helper.rb | 2 + .../lib/active_job/continuation/validation.rb | 50 ++++++ activejob/lib/active_job/instrumentation.rb | 4 +- activejob/lib/active_job/log_subscriber.rb | 42 ++--- activejob/test/cases/continuation_test.rb | 144 ++++++++++++++++++ activerecord/lib/active_record/railtie.rb | 7 + .../active_record/railties/job_checkpoints.rb | 15 ++ .../test/activejob/job_checkpoints_test.rb | 42 +++++ .../application/active_record_railtie_test.rb | 35 +++++ 12 files changed, 431 insertions(+), 110 deletions(-) create mode 100644 activejob/lib/active_job/continuation/validation.rb create mode 100644 activerecord/lib/active_record/railties/job_checkpoints.rb create mode 100644 activerecord/test/activejob/job_checkpoints_test.rb create mode 100644 railties/test/application/active_record_railtie_test.rb diff --git a/activejob/lib/active_job/continuable.rb b/activejob/lib/active_job/continuable.rb index 6cdbd923e1d88..b81926def4636 100644 --- a/activejob/lib/active_job/continuable.rb +++ b/activejob/lib/active_job/continuable.rb @@ -11,15 +11,26 @@ module ActiveJob module Continuable extend ActiveSupport::Concern - CONTINUATION_KEY = "continuation" - included do - retry_on Continuation::Interrupt, attempts: :unlimited - retry_on Continuation::AfterAdvancingError, attempts: :unlimited + class_attribute :max_resumptions, instance_writer: false + class_attribute :resume_options, instance_writer: false, default: { wait: 5.seconds } + class_attribute :resume_errors_after_advancing, instance_writer: false, default: true around_perform :continue + + def initialize(...) + super(...) + self.resumptions = 0 + self.continuation = Continuation.new(self, {}) + end end + # The number of times the job has been resumed. + attr_accessor :resumptions + + attr_accessor :continuation # :nodoc: + + # Start a new continuation step def step(step_name, start: nil, &block) unless block_given? step_method = method(step_name) @@ -32,25 +43,58 @@ def step(step_name, start: nil, &block) block = step_method.arity == 0 ? -> (_) { step_method.call } : step_method end + checkpoint! if continuation.advanced? continuation.step(step_name, start: start, &block) end - def serialize - super.merge(CONTINUATION_KEY => continuation.to_h) + def serialize # :nodoc: + super.merge("continuation" => continuation.to_h, "resumptions" => resumptions) end - def deserialize(job_data) + def deserialize(job_data) # :nodoc: super - @continuation = Continuation.new(self, job_data.fetch(CONTINUATION_KEY, {})) + self.continuation = Continuation.new(self, job_data.fetch("continuation", {})) + self.resumptions = job_data.fetch("resumptions", 0) + end + + def checkpoint! # :nodoc: + interrupt! if queue_adapter.stopping? end private - def continuation - @continuation ||= Continuation.new(self, {}) + def continue(&block) + if continuation.started? + self.resumptions += 1 + instrument :resume, **continuation.instrumentation + end + + block.call + rescue Continuation::Interrupt => e + resume_job(e) + rescue Continuation::Error + raise + rescue StandardError => e + if resume_errors_after_advancing? && continuation.advanced? + resume_job(exception: e) + else + raise + end end - def continue(&block) - continuation.continue(&block) + def resume_job(exception) # :nodoc: + executions_for(exception) + if max_resumptions.nil? || resumptions < max_resumptions + retry_job(**self.resume_options) + else + raise Continuation::ResumeLimitError, "Job was resumed a maximum of #{max_resumptions} times" + end + end + + def interrupt! # :nodoc: + instrument :interrupt, **continuation.instrumentation + raise Continuation::Interrupt, "Interrupted #{continuation.description}" end end + + ActiveSupport.run_load_hooks(:active_job_continuable, Continuable) end diff --git a/activejob/lib/active_job/continuation.rb b/activejob/lib/active_job/continuation.rb index 6211b279dafd6..9bc0c6f926421 100644 --- a/activejob/lib/active_job/continuation.rb +++ b/activejob/lib/active_job/continuation.rb @@ -134,8 +134,8 @@ module ActiveJob # +queue_adapter.stopping?+. If it returns true, the job will raise an # ActiveJob::Continuation::Interrupt exception. # - # There is an automatic checkpoint at the end of each step. Within a step one is - # created when calling +set!+, +advance!+ or +checkpoint!+. + # There is an automatic checkpoint before the start of each step except for the first for + # each job execution. Within a step one is created when calling +set!+, +advance!+ or +checkpoint!+. # # Jobs are not automatically interrupted when the queue adapter is marked as stopping - they # will continue to run either until the next checkpoint, or when the process is stopped. @@ -158,49 +158,57 @@ module ActiveJob # To mitigate this, the job will be automatically retried if it raises an error after it has made progress. # Making progress is defined as having completed a step or advanced the cursor within the current step. # + # === Configuration + # + # Continuable jobs have several configuration options: + # * :max_resumptions - The maximum number of times a job can be resumed. Defaults to +nil+ which means + # unlimited resumptions. + # * :resume_options - Options to pass to +retry_job+ when resuming the job. + # Defaults to +{ wait: 5.seconds }+. + # See +ActiveJob::Exceptions#retry_job+ for available options. + # * :resume_errors_after_advancing - Whether to resume errors after advancing the continuation. + # Defaults to +true+. class Continuation extend ActiveSupport::Autoload autoload :Step + autoload :Validation # Raised when a job is interrupted, allowing Active Job to requeue it. # This inherits from +Exception+ rather than +StandardError+, so it's not # caught by normal exception handling. class Interrupt < Exception; end - # Base error class for all Continuation errors. + # Base class for all Continuation errors. class Error < StandardError; end # Raised when a step is invalid. class InvalidStepError < Error; end + # Raised when there is an error with a checkpoint, such as open database transactions. + class CheckpointError < Error; end + # Raised when attempting to advance a cursor that doesn't implement `succ`. class UnadvanceableCursorError < Error; end - # Raised when an error occurs after a job has made progress. - # - # The job will be automatically retried to ensure that the progress is serialized - # in the retried job. - class AfterAdvancingError < Error; end + # Raised when a job has reached its limit of the number of resumes. + # The limit is defined by the +max_resumes+ class attribute. + class ResumeLimitError < Error; end - def initialize(job, serialized_progress) + include Validation + + def initialize(job, serialized_progress) # :nodoc: @job = job @completed = serialized_progress.fetch("completed", []).map(&:to_sym) @current = new_step(*serialized_progress["current"], resumed: true) if serialized_progress.key?("current") - @encountered_step_names = [] + @encountered = [] @advanced = false @running_step = false end - def continue(&block) - wrapping_errors_after_advancing do - instrument_job :resume if started? - block.call - end - end - - def step(name, start:, &block) + def step(name, start:, &block) # :nodoc: validate_step!(name) + encountered << name if completed?(name) skip_step(name) @@ -209,14 +217,14 @@ def step(name, start:, &block) end end - def to_h + def to_h # :nodoc: { "completed" => completed.map(&:to_s), "current" => current&.to_a }.compact end - def description + def description # :nodoc: if current current.description elsif completed.any? @@ -226,36 +234,33 @@ def description end end - private - attr_reader :job, :encountered_step_names, :completed, :current + def started? + completed.any? || current.present? + end - def advanced? - @advanced - end + def advanced? + @advanced + end + + def instrumentation + { description: description, + completed_steps: completed, + current_step: current } + end + + private + attr_reader :job, :encountered, :completed, :current def running_step? @running_step end - def started? - completed.any? || current.present? - end - def completed?(name) completed.include?(name) end - def validate_step!(name) - raise InvalidStepError, "Step '#{name}' must be a Symbol, found '#{name.class}'" unless name.is_a?(Symbol) - raise InvalidStepError, "Step '#{name}' has already been encountered" if encountered_step_names.include?(name) - raise InvalidStepError, "Step '#{name}' is nested inside step '#{current.name}'" if running_step? - raise InvalidStepError, "Step '#{name}' found, expected to resume from '#{current.name}'" if current && current.name != name && !completed?(name) - - encountered_step_names << name - end - def new_step(*args, **options) - Step.new(*args, **options) { checkpoint! } + Step.new(*args, job: job, **options) end def skip_step(name) @@ -273,49 +278,24 @@ def run_step(name, start:, &block) @completed << current.name @current = nil @advanced = true - - checkpoint! ensure @running_step = false @advanced ||= current&.advanced? end - def interrupt! - instrument_job :interrupt - raise Interrupt, "Interrupted #{description}" - end - - def checkpoint! - interrupt! if job.queue_adapter.stopping? - end + def instrumenting_step(step, &block) + instrument :step, step: step, interrupted: false do |payload| + instrument :step_started, step: step - def wrapping_errors_after_advancing(&block) - block.call - rescue StandardError => e - if !e.is_a?(Error) && advanced? - raise AfterAdvancingError, "Advanced job failed with error: #{e.message}" - else + block.call + rescue Interrupt + payload[:interrupted] = true raise end end - def instrumenting_step(step, &block) - instrument (step.resumed? ? :step_resumed : :step_started), step: step - - block.call - - instrument :step_completed, step: step - rescue Interrupt - instrument :step_interrupted, step: step - raise - end - - def instrument_job(event) - instrument event, description: description, completed_steps: completed, current_step: current - end - - def instrument(event, payload = {}) - job.instrument event, **payload + def instrument(...) + job.instrument(...) end end end diff --git a/activejob/lib/active_job/continuation/step.rb b/activejob/lib/active_job/continuation/step.rb index 71f8ce63bbcc9..53cad508a895a 100644 --- a/activejob/lib/active_job/continuation/step.rb +++ b/activejob/lib/active_job/continuation/step.rb @@ -22,18 +22,18 @@ class Step # The cursor for the step. attr_reader :cursor - def initialize(name, cursor, resumed:, &checkpoint_callback) + def initialize(name, cursor, job:, resumed:) @name = name.to_sym @initial_cursor = cursor @cursor = cursor @resumed = resumed - @checkpoint_callback = checkpoint_callback + @job = job end # Check if the job should be interrupted, and if so raise an Interrupt exception. # The job will be requeued for retry. def checkpoint! - checkpoint_callback.call + job.checkpoint! end # Set the cursor and interrupt the job if necessary. @@ -77,7 +77,7 @@ def description end private - attr_reader :checkpoint_callback, :initial_cursor + attr_reader :initial_cursor, :job end end end diff --git a/activejob/lib/active_job/continuation/test_helper.rb b/activejob/lib/active_job/continuation/test_helper.rb index ee9b0bdd2967d..2e7a06fda76e8 100644 --- a/activejob/lib/active_job/continuation/test_helper.rb +++ b/activejob/lib/active_job/continuation/test_helper.rb @@ -40,6 +40,8 @@ def interrupt_job_during_step(job, step, cursor: nil, &block) # Interrupt a job after a step. # + # Note that there's no checkpoint after the final step so it won't be interrupted. + # # class MyJob < ApplicationJob # include ActiveJob::Continuable # diff --git a/activejob/lib/active_job/continuation/validation.rb b/activejob/lib/active_job/continuation/validation.rb new file mode 100644 index 0000000000000..50162bdd14bf5 --- /dev/null +++ b/activejob/lib/active_job/continuation/validation.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module ActiveJob + class Continuation + module Validation # :nodoc: + private + def validate_step!(name) + validate_step_symbol!(name) + validate_step_not_encountered!(name) + validate_step_not_nested!(name) + validate_step_resume_expected!(name) + validate_step_expected_order!(name) + end + + def validate_step_symbol!(name) + unless name.is_a?(Symbol) + raise_step_error! "Step '#{name}' must be a Symbol, found '#{name.class}'" + end + end + + def validate_step_not_encountered!(name) + if encountered.include?(name) + raise_step_error! "Step '#{name}' has already been encountered" + end + end + + def validate_step_not_nested!(name) + if running_step? + raise_step_error! "Step '#{name}' is nested inside step '#{current.name}'" + end + end + + def validate_step_resume_expected!(name) + if current && current.name != name && !completed?(name) + raise_step_error! "Step '#{name}' found, expected to resume from '#{current.name}'" + end + end + + def validate_step_expected_order!(name) + if completed.size > encountered.size && completed[encountered.size] != name + raise_step_error! "Step '#{name}' found, expected to see '#{completed[encountered.size]}'" + end + end + + def raise_step_error!(message) + raise InvalidStepError, message + end + end + end +end diff --git a/activejob/lib/active_job/instrumentation.rb b/activejob/lib/active_job/instrumentation.rb index 4364ae8bedabe..cc103b75b9e05 100644 --- a/activejob/lib/active_job/instrumentation.rb +++ b/activejob/lib/active_job/instrumentation.rb @@ -30,8 +30,8 @@ def instrument(operation, payload = {}, &block) # :nodoc: payload[:job] = self payload[:adapter] = queue_adapter - ActiveSupport::Notifications.instrument("#{operation}.active_job", payload) do - value = block.call if block + ActiveSupport::Notifications.instrument("#{operation}.active_job", payload) do |payload| + value = block.call(payload) if block payload[:aborted] = @_halted_callback_hook_called if defined?(@_halted_callback_hook_called) @_halted_callback_hook_called = nil value diff --git a/activejob/lib/active_job/log_subscriber.rb b/activejob/lib/active_job/log_subscriber.rb index ce109af6df6ce..3f64893b798f9 100644 --- a/activejob/lib/active_job/log_subscriber.rb +++ b/activejob/lib/active_job/log_subscriber.rb @@ -164,35 +164,37 @@ def step_skipped(event) def step_started(event) job = event.payload[:job] + step = event.payload[:step] info do - "Step '#{event.payload[:step].name}' started #{job.class}" + if step.resumed? + "Step '#{step.name}' resumed from cursor '#{step.cursor}' for #{job.class} (Job ID: #{job.job_id})" + else + "Step '#{step.name}' started for #{job.class} (Job ID: #{job.job_id})" + end end end subscribe_log_level :step_started, :info - def step_interrupted(event) - job = event.payload[:job] - info do - "Step '#{event.payload[:step].name}' interrupted at cursor '#{event.payload[:step].cursor}' #{job.class}" - end - end - subscribe_log_level :step_completed, :info - - def step_resumed(event) + def step(event) job = event.payload[:job] - info do - "Step '#{event.payload[:step].name}' resumed from cursor '#{event.payload[:step].cursor}' #{job.class}" - end - end - subscribe_log_level :step_resumed, :info + step = event.payload[:step] + ex = event.payload[:exception_object] - def step_completed(event) - job = event.payload[:job] - info do - "Step '#{event.payload[:step].name}' completed #{job.class}" + if event.payload[:interrupted] + info do + "Step '#{step.name}' interrupted at cursor '#{step.cursor}' for #{job.class} (Job ID: #{job.job_id}) in #{event.duration.round(2)}ms" + end + elsif ex + error do + "Error during step '#{step.name}' at cursor '#{step.cursor}' for #{job.class} (Job ID: #{job.job_id}) in #{event.duration.round(2)}ms: #{ex.class} (#{ex.message})" + end + else + info do + "Step '#{step.name}' completed for #{job.class} (Job ID: #{job.job_id}) in #{event.duration.round(2)}ms" + end end end - subscribe_log_level :step_completed, :info + subscribe_log_level :step, :error private def queue_name(event) diff --git a/activejob/test/cases/continuation_test.rb b/activejob/test/cases/continuation_test.rb index 3bf81f4845e26..b8e11b553e073 100644 --- a/activejob/test/cases/continuation_test.rb +++ b/activejob/test/cases/continuation_test.rb @@ -133,6 +133,25 @@ def step_four assert_equal %w[ item1 item2 item3 item4 ], LinearJob.items end + test "does not checkpoint after the last step" do + LinearJob.items = [] + LinearJob.perform_later + + interrupt_job_after_step LinearJob, :step_three do + assert_enqueued_jobs 1, only: LinearJob do + perform_enqueued_jobs + end + end + + interrupt_job_after_step LinearJob, :step_four do + assert_enqueued_jobs 0, only: LinearJob do + perform_enqueued_jobs + end + end + + assert_equal %w[ item1 item2 item3 item4 ], LinearJob.items + end + test "runs with perform_now" do LinearJob.items = [] LinearJob.perform_now @@ -356,6 +375,37 @@ def perform assert_equal "Step 'unexpected' found, expected to resume from 'iterating'", exception.message end + class ChangedStepOrderJob < ContinuableJob + def perform + if continuation.send(:started?) + step :step_one do; end + step :step_two do; end + step :step_two_and_a_half do; end + step :step_three do; end + step :step_four do; end + else + step :step_one do; end + step :step_two do; end + step :step_three do; end + step :step_four do; end + end + end + end + + test "steps not matching previously completed raises an error" do + ChangedStepOrderJob.perform_later + + interrupt_job_after_step ChangedStepOrderJob, :step_three do + perform_enqueued_jobs + end + + exception = assert_raises ActiveJob::Continuation::InvalidStepError do + perform_enqueued_jobs + end + + assert_equal "Step 'step_two_and_a_half' found, expected to see 'step_three'", exception.message + end + class AdvancingJob < ContinuableJob def perform(start_from, advance_from = nil) step :test_step, start: start_from do |step| @@ -507,6 +557,100 @@ def perform(objects) assert_equal objects, ArrayCursorJob.items end + class LimitedResumesJob < ContinuableJob + self.max_resumptions = 2 + + def perform(iterations) + step :iterate, start: 0 do |step| + (step.cursor..iterations).each do |i| + step.advance! + end + end + end + end + + test "limits resumes" do + LimitedResumesJob.perform_later(10) + + interrupt_job_during_step LimitedResumesJob, :iterate, cursor: 1 do + assert_enqueued_jobs 1, only: LimitedResumesJob do + perform_enqueued_jobs + end + end + + interrupt_job_during_step LimitedResumesJob, :iterate, cursor: 2 do + assert_enqueued_jobs 1, only: LimitedResumesJob do + perform_enqueued_jobs + end + end + + interrupt_job_during_step LimitedResumesJob, :iterate, cursor: 3 do + assert_enqueued_jobs 0, only: LimitedResumesJob do + exception = assert_raises ActiveJob::Continuation::ResumeLimitError do + perform_enqueued_jobs + end + + assert_equal "Job was resumed a maximum of 2 times", exception.message + end + end + end + + test "limits resumes due to errors" do + LimitedResumesJob.perform_later(10) + + queue_adapter.with(stopping: ->() { raise StandardError if during_step?(LimitedResumesJob, :iterate, cursor: 1) }) do + assert_enqueued_jobs 1, only: LimitedResumesJob do + perform_enqueued_jobs + end + end + + queue_adapter.with(stopping: ->() { raise StandardError if during_step?(LimitedResumesJob, :iterate, cursor: 2) }) do + assert_enqueued_jobs 1, only: LimitedResumesJob do + perform_enqueued_jobs + end + end + + queue_adapter.with(stopping: ->() { raise StandardError if during_step?(LimitedResumesJob, :iterate, cursor: 3) }) do + assert_enqueued_jobs 0, only: LimitedResumesJob do + exception = assert_raises ActiveJob::Continuation::ResumeLimitError do + perform_enqueued_jobs + end + + assert_equal "Job was resumed a maximum of 2 times", exception.message + end + end + end + + test "does not resume after an error" do + LimitedResumesJob.with(resume_errors_after_advancing: false) do + LimitedResumesJob.perform_later(10) + + queue_adapter.with(stopping: ->() { raise StandardError, "boom" if during_step?(LimitedResumesJob, :iterate, cursor: 5) }) do + assert_enqueued_jobs 0, only: LimitedResumesJob do + exception = assert_raises StandardError do + perform_enqueued_jobs + end + + assert_equal "boom", exception.message + end + end + end + end + + test "resume options" do + LimitedResumesJob.with(resume_options: { queue: :other, wait: 8 }) do + freeze_time + + LimitedResumesJob.perform_later(10) + + interrupt_job_during_step LimitedResumesJob, :iterate, cursor: 1 do + assert_enqueued_with job: LimitedResumesJob, queue: :other, at: Time.now + 8.seconds do + perform_enqueued_jobs + end + end + end + end + private def capture_info_stdout(&block) ActiveJob::Base.logger.with(level: :info) do diff --git a/activerecord/lib/active_record/railtie.rb b/activerecord/lib/active_record/railtie.rb index f907c323f06c9..582c4d9840768 100644 --- a/activerecord/lib/active_record/railtie.rb +++ b/activerecord/lib/active_record/railtie.rb @@ -278,6 +278,13 @@ class Railtie < Rails::Railtie # :nodoc: end end + initializer "active_record.job_checkpoints" do + require "active_record/railties/job_checkpoints" + ActiveSupport.on_load(:active_job_continuable) do + prepend ActiveRecord::Railties::JobCheckpoints + end + end + initializer "active_record.set_reloader_hooks" do ActiveSupport.on_load(:active_record) do ActiveSupport::Reloader.before_class_unload do diff --git a/activerecord/lib/active_record/railties/job_checkpoints.rb b/activerecord/lib/active_record/railties/job_checkpoints.rb new file mode 100644 index 0000000000000..c2b4a3535a302 --- /dev/null +++ b/activerecord/lib/active_record/railties/job_checkpoints.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module ActiveRecord + module Railties # :nodoc: + module JobCheckpoints # :nodoc: + def checkpoint! + if ActiveRecord.all_open_transactions.any? + raise ActiveJob::Continuation::CheckpointError, "Cannot checkpoint job with open transactions" + else + super + end + end + end + end +end diff --git a/activerecord/test/activejob/job_checkpoints_test.rb b/activerecord/test/activejob/job_checkpoints_test.rb new file mode 100644 index 0000000000000..cc1b174c0404b --- /dev/null +++ b/activerecord/test/activejob/job_checkpoints_test.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require "activejob/helper" +require "active_job/continuable" +require "active_record/railties/job_checkpoints" + +class JobCheckpointTest < ActiveSupport::TestCase + class CheckpointInTransactionJob < ActiveJob::Base + include ActiveJob::Continuable + include ActiveRecord::Railties::JobCheckpoints + + def perform(*) + step :checkpoint_in_transaction do |step| + ActiveRecord::Base.transaction do + step.checkpoint! + end + end + end + end + + class CheckpointOutsideTransactionJob < ActiveJob::Base + include ActiveJob::Continuable + include ActiveRecord::Railties::JobCheckpoints + + def perform(*) + step :checkpoint_outside_transaction do |step| + ActiveRecord::Base.transaction do + end + step.checkpoint! + end + end + end + + test "checkpoints in transactions raise" do + exception = assert_raises { CheckpointInTransactionJob.perform_now } + assert_equal "Cannot checkpoint job with open transactions", exception.message + end + + test "checkpoints outside transactions complete" do + assert_nothing_raised { CheckpointOutsideTransactionJob.perform_now } + end +end diff --git a/railties/test/application/active_record_railtie_test.rb b/railties/test/application/active_record_railtie_test.rb new file mode 100644 index 0000000000000..05a516644f2d5 --- /dev/null +++ b/railties/test/application/active_record_railtie_test.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require "isolation/abstract_unit" + +module ApplicationTests + class ActiveRecordRailtieTest < ActiveSupport::TestCase + include ActiveSupport::Testing::Isolation + + setup :build_app + teardown :teardown_app + + test "continuable jobs raise when checkpointing in a transaction" do + app_file "app/jobs/continuable_job.rb", <<~RUBY + require "active_job/continuable" + + class ContinuableJob < ActiveJob::Base + include ActiveJob::Continuable + + def perform(*) + step :checkpoint_in_transaction do |step| + ActiveRecord::Base.transaction do + step.checkpoint! + end + end + end + end + RUBY + + exception = assert_raises do + rails("runner", "ContinuableJob.perform_now") + end + assert_includes exception.message, "ActiveJob::Continuation::CheckpointError: Cannot checkpoint job with open transactions" + end + end +end From 4de851822992061297c38762371f9483a482a7c5 Mon Sep 17 00:00:00 2001 From: eileencodes Date: Thu, 5 Jun 2025 14:09:34 -0400 Subject: [PATCH 0215/1075] Add ability to change the transaction isolation for all pools within a block rails/rails#54836 introduced the ability to change the isolation for the current pool. If you're changing the default transaction isolation for an app with many dbs/shards you would need to loop through all pools and apply the isolation. If there are hundreds of connections, that might create a performance issue. This change adds a new method `ActiveRecord.with_transaction_isolation_level` which will change the transaction isolation for any pool accessed within the block. The method can be used in an around filter to update all transactions for an action or middleware to switch all pools accessed in a request. I chose to call this from `with_transaction_returning_status` and `transaction` because called from within `connection.transaction` made it too difficult to tell if we were changing the isolation on an open transaction. It would either error when it wasn't actually changing or fail to error when it was supposed to. Wrapping the calls solves this. While implementing this I felt that the original method had a confusing name paired with the new one so I updated it to be more explicit that it's for only the currently current pool. --- activerecord/CHANGELOG.md | 27 ++++ activerecord/lib/active_record.rb | 17 +++ .../abstract/connection_pool.rb | 30 ++++- .../abstract/transaction.rb | 2 +- .../lib/active_record/transactions.rb | 41 +++--- .../test/cases/transaction_isolation_test.rb | 125 ++++++++++++++++-- 6 files changed, 203 insertions(+), 39 deletions(-) diff --git a/activerecord/CHANGELOG.md b/activerecord/CHANGELOG.md index 98ba4f586f76e..bacb4ee6a5741 100644 --- a/activerecord/CHANGELOG.md +++ b/activerecord/CHANGELOG.md @@ -1,3 +1,30 @@ +* Add ability to change transaction isolation for all pools within a block. + + This functionality is useful if your application needs to change the database + transaction isolation for a request or action. + + Calling `ActiveRecord.with_transaction_isolation_level(level) {}` in an around filter or + middleware will set the transaction isolation for all pools accessed within the block, + but not for the pools that aren't. + + This works with explicit and implicit transactions: + + ```ruby + ActiveRecord.with_transaction_isolation_level(:read_committed) do + Tag.transaction do # opens a transaction explicitly + Tag.create! + end + end + ``` + + ```ruby + ActiveRecord.with_transaction_isolation_level(:read_committed) do + Tag.create! # opens a transaction implicitly + end + ``` + + *Eileen M. Uchitelle* + * `:class_name` is now invalid in polymorphic `belongs_to` associations. Reason is `:class_name` does not make sense in those associations because diff --git a/activerecord/lib/active_record.rb b/activerecord/lib/active_record.rb index 4be9a58ea175f..a9350f5bfae0c 100644 --- a/activerecord/lib/active_record.rb +++ b/activerecord/lib/active_record.rb @@ -574,6 +574,23 @@ def self.all_open_transactions # :nodoc: end open_transactions end + + def self.default_transaction_isolation_level=(isolation_level) # :nodoc: + ActiveSupport::IsolatedExecutionState[:active_record_transaction_isolation] = isolation_level + end + + def self.default_transaction_isolation_level # :nodoc: + ActiveSupport::IsolatedExecutionState[:active_record_transaction_isolation] + end + + # Sets a transaction isolation level for all connection pools within the block. + def self.with_transaction_isolation_level(isolation_level, &block) + original_level = self.default_transaction_isolation_level + self.default_transaction_isolation_level = isolation_level + yield + ensure + self.default_transaction_isolation_level = original_level + end end ActiveSupport.on_load(:active_record) do diff --git a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb index b443cef723f57..6db1a00e4ee36 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb @@ -49,8 +49,8 @@ def dirties_query_cache true end - def default_isolation_level; end - def default_isolation_level=(isolation_level) + def pool_transaction_isolation_level; end + def pool_transaction_isolation_level=(isolation_level) raise NotImplementedError, "This method should never be called" end end @@ -428,6 +428,24 @@ def with_connection(prevent_permanent_checkout: false) end end + def with_pool_transaction_isolation_level(isolation_level, transaction_open, &block) # :nodoc: + if !ActiveRecord.default_transaction_isolation_level.nil? + begin + if transaction_open && self.pool_transaction_isolation_level != ActiveRecord.default_transaction_isolation_level + raise ActiveRecord::TransactionIsolationError, "cannot set default isolation level while transaction is open" + end + + old_level = self.pool_transaction_isolation_level + self.pool_transaction_isolation_level = isolation_level + yield + ensure + self.pool_transaction_isolation_level = old_level + end + else + yield + end + end + # Returns true if a connection has already been opened. def connected? synchronize { @connections.any?(&:connected?) } @@ -711,13 +729,13 @@ def new_connection # :nodoc: raise ex.set_pool(self) end - def default_isolation_level - isolation_level_key = "activerecord_default_isolation_level_#{db_config.name}" + def pool_transaction_isolation_level + isolation_level_key = "activerecord_pool_transaction_isolation_level_#{db_config.name}" ActiveSupport::IsolatedExecutionState[isolation_level_key] end - def default_isolation_level=(isolation_level) - isolation_level_key = "activerecord_default_isolation_level_#{db_config.name}" + def pool_transaction_isolation_level=(isolation_level) + isolation_level_key = "activerecord_pool_transaction_isolation_level_#{db_config.name}" ActiveSupport::IsolatedExecutionState[isolation_level_key] = isolation_level end diff --git a/activerecord/lib/active_record/connection_adapters/abstract/transaction.rb b/activerecord/lib/active_record/connection_adapters/abstract/transaction.rb index 1076575f898eb..aca1822b45f37 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/transaction.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/transaction.rb @@ -620,7 +620,7 @@ def rollback_transaction(transaction = nil) end def within_new_transaction(isolation: nil, joinable: true) - isolation ||= @connection.pool.default_isolation_level + isolation ||= @connection.pool.pool_transaction_isolation_level @connection.lock.synchronize do transaction = begin_transaction(isolation: isolation, joinable: joinable) begin diff --git a/activerecord/lib/active_record/transactions.rb b/activerecord/lib/active_record/transactions.rb index eac7f07b4ba6a..8efb066a9087d 100644 --- a/activerecord/lib/active_record/transactions.rb +++ b/activerecord/lib/active_record/transactions.rb @@ -230,27 +230,28 @@ module ClassMethods # See the ConnectionAdapters::DatabaseStatements#transaction API docs. def transaction(**options, &block) with_connection do |connection| - connection.transaction(**options, &block) + connection.pool.with_pool_transaction_isolation_level(ActiveRecord.default_transaction_isolation_level, connection.transaction_open?) do + connection.transaction(**options, &block) + end end end - # Makes all transactions initiated within the block use the isolation level - # that you set as the default. Useful for gradually migrating apps onto new isolation level. - def with_default_isolation_level(isolation_level, &block) + # Makes all transactions the current pool use the isolation level initiated within the block. + def with_pool_transaction_isolation_level(isolation_level, &block) if current_transaction.open? raise ActiveRecord::TransactionIsolationError, "cannot set default isolation level while transaction is open" end - old_level = connection_pool.default_isolation_level - connection_pool.default_isolation_level = isolation_level + old_level = connection_pool.pool_transaction_isolation_level + connection_pool.pool_transaction_isolation_level = isolation_level yield ensure - connection_pool.default_isolation_level = old_level + connection_pool.pool_transaction_isolation_level = old_level end - # Returns the default isolation level for the connection pool, set earlier by #with_default_isolation_level. - def default_isolation_level - connection_pool.default_isolation_level + # Returns the default isolation level for the connection pool, set earlier by #with_pool_transaction_isolation_level. + def pool_transaction_isolation_level + connection_pool.pool_transaction_isolation_level end # Returns a representation of the current transaction state, @@ -426,18 +427,20 @@ def rolledback!(force_restore_state: false, should_run_callbacks: true) # :nodoc # instance. def with_transaction_returning_status self.class.with_connection do |connection| - status = nil - ensure_finalize = !connection.transaction_open? + connection.pool.with_pool_transaction_isolation_level(ActiveRecord.default_transaction_isolation_level, connection.transaction_open?) do + status = nil + ensure_finalize = !connection.transaction_open? - connection.transaction do - add_to_transaction(ensure_finalize || has_transactional_callbacks?) - remember_transaction_record_state + connection.transaction do + add_to_transaction(ensure_finalize || has_transactional_callbacks?) + remember_transaction_record_state - status = yield - raise ActiveRecord::Rollback unless status + status = yield + raise ActiveRecord::Rollback unless status + end + @_last_transaction_return_status = status + status end - @_last_transaction_return_status = status - status end end diff --git a/activerecord/test/cases/transaction_isolation_test.rb b/activerecord/test/cases/transaction_isolation_test.rb index eeb9f4fd61f42..38d0045d89a63 100644 --- a/activerecord/test/cases/transaction_isolation_test.rb +++ b/activerecord/test/cases/transaction_isolation_test.rb @@ -29,10 +29,16 @@ class Tag2 < ActiveRecord::Base self.table_name = "tags" end + class Dog < ARUnit2Model + self.table_name = "dogs" + end + setup do Tag.establish_connection :arunit Tag2.establish_connection :arunit + Dog.establish_connection :arunit2 Tag.destroy_all + Dog.destroy_all end # It is impossible to properly test read uncommitted. The SQL standard only @@ -62,16 +68,16 @@ class Tag2 < ActiveRecord::Base assert_equal 1, Tag.count end - test "default_isolation_level" do - assert_nil Tag.default_isolation_level + test "pool_transaction_isolation_level" do + assert_nil Tag.pool_transaction_isolation_level events = [] ActiveSupport::Notifications.subscribed( -> (event) { events << event.payload[:sql] }, "sql.active_record", ) do - Tag.with_default_isolation_level(:read_committed) do - assert_equal :read_committed, Tag.default_isolation_level + Tag.with_pool_transaction_isolation_level(:read_committed) do + assert_equal :read_committed, Tag.pool_transaction_isolation_level Tag.transaction do Tag.create!(name: "jon") end @@ -80,24 +86,24 @@ class Tag2 < ActiveRecord::Base assert_begin_isolation_level_event(events) end - test "default_isolation_level cannot be set within open transaction" do + test "pool_transaction_isolation_level cannot be set within open transaction" do assert_raises(ActiveRecord::TransactionIsolationError) do Tag.transaction do - Tag.with_default_isolation_level(:read_committed) { } + Tag.with_pool_transaction_isolation_level(:read_committed) { } end end end - test "default_isolation_level but transaction overrides isolation" do - assert_nil Tag.default_isolation_level + test "pool_transaction_isolation_level but transaction overrides isolation" do + assert_nil Tag.pool_transaction_isolation_level events = [] ActiveSupport::Notifications.subscribed( -> (event) { events << event.payload[:sql] }, "sql.active_record", ) do - Tag.with_default_isolation_level(:read_committed) do - assert_equal :read_committed, Tag.default_isolation_level + Tag.with_pool_transaction_isolation_level(:read_committed) do + assert_equal :read_committed, Tag.pool_transaction_isolation_level Tag.transaction(isolation: :repeatable_read) do Tag.create!(name: "jon") @@ -108,6 +114,99 @@ class Tag2 < ActiveRecord::Base assert_begin_isolation_level_event(events, isolation: "REPEATABLE READ") end + test "with_transaction_isolation_level explicit transaction" do + assert_nil ActiveRecord.default_transaction_isolation_level + + events = [] + ActiveSupport::Notifications.subscribed( + -> (event) { events << event.payload[:sql] }, + "sql.active_record", + ) do + assert_nil Tag.connection_pool.pool_transaction_isolation_level + assert_nil Dog.connection_pool.pool_transaction_isolation_level + + ActiveRecord.with_transaction_isolation_level(:read_committed) do + assert_equal :read_committed, ActiveRecord.default_transaction_isolation_level + Tag.transaction do + assert_equal :read_committed, Tag.connection_pool.pool_transaction_isolation_level + assert_equal :read_committed, Dog.connection_pool.pool_transaction_isolation_level + + Tag.create!(name: "jon") + Dog.create! + end + end + end + + assert_nil Tag.connection_pool.pool_transaction_isolation_level + assert_nil Dog.connection_pool.pool_transaction_isolation_level + assert_begin_isolation_level_event(events, count: 2) + end + + test "with_transaction_isolation_level implicit transaction" do + assert_nil ActiveRecord.default_transaction_isolation_level + + events = [] + ActiveSupport::Notifications.subscribed( + -> (event) { events << event.payload[:sql] }, + "sql.active_record", + ) do + ActiveRecord.with_transaction_isolation_level(:read_committed) do + assert_equal :read_committed, ActiveRecord.default_transaction_isolation_level + + Tag.create!(name: "jon") + Dog.create! + end + end + + assert_begin_isolation_level_event(events, count: 2) + end + + test "with_transaction_isolation_level cannot be set within open transaction" do + Tag.transaction do + assert_raises(ActiveRecord::TransactionIsolationError) do + ActiveRecord.with_transaction_isolation_level(:repeatable_read) do + Tag.create!(name: "some tag") + end + end + end + end + + test "with_transaction_isolation_level cannot be changed within the block" do + Tag.transaction do + assert_raises(ActiveRecord::TransactionIsolationError) do + ActiveRecord.with_transaction_isolation_level(:repeatable_read) do + Tag.transaction do + ActiveRecord.with_transaction_isolation_level(:serializable) do + assert_raises do + Tag.create!(name: "some tag") + end + end + end + end + end + end + end + + test "with_transaction_isolation_level but transaction overrides isolation" do + assert_nil ActiveRecord.default_transaction_isolation_level + + events = [] + ActiveSupport::Notifications.subscribed( + -> (event) { events << event.payload[:sql] }, + "sql.active_record", + ) do + ActiveRecord.with_transaction_isolation_level(:read_committed) do + assert_equal :read_committed, ActiveRecord.default_transaction_isolation_level + + Dog.transaction(isolation: :repeatable_read) do + Dog.create! + end + end + end + + assert_begin_isolation_level_event(events, isolation: "REPEATABLE READ") + end + # We are testing that a nonrepeatable read does not happen if ActiveRecord::Base.lease_connection.transaction_isolation_levels.include?(:repeatable_read) test "repeatable read" do @@ -154,11 +253,11 @@ class Tag2 < ActiveRecord::Base end private - def assert_begin_isolation_level_event(events, isolation: "READ COMMITTED") + def assert_begin_isolation_level_event(events, isolation: "READ COMMITTED", count: 1) if current_adapter?(:PostgreSQLAdapter) - assert_equal 1, events.select { _1.match(/BEGIN ISOLATION LEVEL #{isolation}/) }.size + assert_equal count, events.select { _1.match(/BEGIN ISOLATION LEVEL #{isolation}/) }.size else - assert_equal 1, events.select { _1.match(/SET TRANSACTION ISOLATION LEVEL #{isolation}/) }.size + assert_equal count, events.select { _1.match(/SET TRANSACTION ISOLATION LEVEL #{isolation}/) }.size end end end From e2010346257bb3be177e5df87e46fda3051b3285 Mon Sep 17 00:00:00 2001 From: Hartley McGuire Date: Tue, 10 Jun 2025 15:37:06 -0400 Subject: [PATCH 0216/1075] Temporarily pin devcontainer Ruby Until its updated to use Mise. --- .devcontainer/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 72eb8ea62754f..fc5380bfd5dab 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,7 +1,7 @@ # See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.191.1/containers/ruby/.devcontainer/base.Dockerfile # [Choice] Ruby version: 3.4, 3.3, 3.2 -ARG VARIANT="3.4.4" +ARG VARIANT="1.1.3-3.4.4" FROM ghcr.io/rails/devcontainer/images/ruby:${VARIANT} RUN sudo apt-get update && export DEBIAN_FRONTEND=noninteractive \ From 38be63eb5ba30257d4cdcb5c93a9205039ec9e47 Mon Sep 17 00:00:00 2001 From: Hartley McGuire Date: Tue, 10 Jun 2025 15:25:00 -0400 Subject: [PATCH 0217/1075] Fix affected_rows for SQLite adapter (again) The [previous change][1] to SQLite's `affected_rows` value switched from `#changes` to `#total_changes` due to `#changes` not being cleared on non-`INSERT/UPDATE/DELETE` statements (meaning `SELECT`s would have `affected_rows` of whatever the last `INSERT/UPDATE/DELETE` was). However, it was pointed out that this value is _also_ inaccurate because it can include "foreign key actions" (specifically, `DELETE` cascades). This commit aims to address both of these issues by combining the usage of `#total_changes` and `#changes`: it uses `#total_changes` to determine whether _any_ rows are affected, and if so, calls `#changes` to get the most accurate value. [1]: 44324036ee0540908f3d1ed53695b95450c74b5d Co-authored-by: Ruy R. <108208+ruyrocha@users.noreply.github.com> --- .../sqlite3/database_statements.rb | 16 +++++++++++-- .../test/cases/instrumentation_test.rb | 23 +++++++++++++++++++ .../test/cases/relation/delete_all_test.rb | 13 ++++++++++- 3 files changed, 49 insertions(+), 3 deletions(-) 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 86f0aa8109d8b..aab5d3809e866 100644 --- a/activerecord/lib/active_record/connection_adapters/sqlite3/database_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/sqlite3/database_statements.rb @@ -103,11 +103,23 @@ def perform_query(raw_connection, sql, binds, type_casted_binds, prepare:, notif end result = if stmt.column_count.zero? # No return stmt.step - affected_rows = raw_connection.total_changes - total_changes_before_query + + affected_rows = if (raw_connection.total_changes - total_changes_before_query) > 0 + raw_connection.changes + else + 0 + end + ActiveRecord::Result.empty(affected_rows: affected_rows) else rows = stmt.to_a - affected_rows = raw_connection.total_changes - total_changes_before_query + + affected_rows = if (raw_connection.total_changes - total_changes_before_query) > 0 + raw_connection.changes + else + 0 + end + ActiveRecord::Result.new(stmt.columns, rows, stmt.types.map { |t| type_map.lookup(t) }, affected_rows: affected_rows) end ensure diff --git a/activerecord/test/cases/instrumentation_test.rb b/activerecord/test/cases/instrumentation_test.rb index 94b3781ee44fd..ec6ac4c12badc 100644 --- a/activerecord/test/cases/instrumentation_test.rb +++ b/activerecord/test/cases/instrumentation_test.rb @@ -4,6 +4,8 @@ require "models/author" require "models/book" require "models/clothing_item" +require "models/lesson" +require "models/student" module ActiveRecord class InstrumentationTest < ActiveRecord::TestCase @@ -181,6 +183,27 @@ def test_payload_affected_rows assert_equal 0, affected_row_values.fifth end + def test_payload_affected_rows_cascade + affected_row_values = [] + + ActiveSupport::Notifications.subscribed( + -> (event) do + unless event.payload[:name].in? ["SCHEMA", "TRANSACTION"] + affected_row_values << event.payload[:affected_rows] + end + end, + "sql.active_record", + ) do + l = Lesson.create!(name: "Algebra") + l.students.create!(name: "Jim") + + Student.delete_all + end + + assert_equal 4, affected_row_values.length + assert_equal 1, affected_row_values.fourth + end + def test_no_instantiation_notification_when_no_records author = Author.create!(id: 100, name: "David") diff --git a/activerecord/test/cases/relation/delete_all_test.rb b/activerecord/test/cases/relation/delete_all_test.rb index ae4304542514f..f06de41a8da22 100644 --- a/activerecord/test/cases/relation/delete_all_test.rb +++ b/activerecord/test/cases/relation/delete_all_test.rb @@ -2,8 +2,10 @@ require "cases/helper" require "models/author" +require "models/lesson" require "models/post" require "models/pet" +require "models/student" require "models/toy" require "models/comment" require "models/cpk" @@ -31,10 +33,19 @@ def test_destroy_all def test_delete_all davids = Author.where(name: "David") - assert_difference("Author.count", -1) { davids.delete_all } + assert_difference("Author.count", -1) do + assert_equal 1, davids.delete_all + end assert_not_predicate davids, :loaded? end + def test_delete_all_return_value_ignores_cascades + student = Student.create(name: "Ruy Rocha") + lesson = Lesson.create(name: "Anything Possible") + student.lessons << lesson + assert_equal 1, Student.delete_all + end + def test_delete_all_with_index_hint davids = Author.where(name: "David").from("#{Author.quoted_table_name} /*! USE INDEX (PRIMARY) */") From 6c1120a803069a597249b202e0c96feda7cc83f3 Mon Sep 17 00:00:00 2001 From: Dmitrijs Zubriks Date: Wed, 11 Jun 2025 02:13:40 +0300 Subject: [PATCH 0218/1075] [ci skip] Add warning note to :source_location tag option (#55167) * Add warning note to :source_location tag option `:source_location` adds significant overhead to each SQL query issued from Rails application. Whilst this behaviour has been noted in changelog, it is not noticeable when reading the official documentation on Rails application configuration. Co-authored-by: Hartley McGuire --- guides/source/configuring.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/guides/source/configuring.md b/guides/source/configuring.md index 009181ac14972..fcab95b29db94 100644 --- a/guides/source/configuring.md +++ b/guides/source/configuring.md @@ -1488,6 +1488,8 @@ Define an `Array` specifying the key/value tags to be inserted in an SQL comment `[ :application, :controller, :action, :job ]`. The available tags are: `:application`, `:controller`, `:namespaced_controller`, `:action`, `:job`, and `:source_location`. +WARNING: Calculating the `:source_location` of a query can be slow, so you should consider its impact if using it in a production environment. + #### `config.active_record.query_log_tags_format` A `Symbol` specifying the formatter to use for tags. Valid values are `:sqlcommenter` and `:legacy`. From 946b38e9042b5b3fe9a41dc684984b8fa38133c0 Mon Sep 17 00:00:00 2001 From: Petrik Date: Wed, 11 Jun 2025 13:17:28 +0200 Subject: [PATCH 0219/1075] Fix ActiveJob::Continuation documentation [ci skip] --- activejob/lib/active_job/continuable.rb | 6 ++++-- activejob/lib/active_job/continuation.rb | 6 +++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/activejob/lib/active_job/continuable.rb b/activejob/lib/active_job/continuable.rb index b81926def4636..d1d11cae71baa 100644 --- a/activejob/lib/active_job/continuable.rb +++ b/activejob/lib/active_job/continuable.rb @@ -3,10 +3,12 @@ module ActiveJob # = Active Job Continuable # + # The Continuable module provides the ability to track the progress of your + # jobs, and continue from where they left off if interrupted. + # # Mix ActiveJob::Continuable into your job to enable continuations. # - # See +ActiveJob::Continuation+ for usage. # The Continuable module provides the ability to track the progress of your jobs, - # and continue from where they left off if interrupted. + # See {ActiveJob::Continuation}[rdoc-ref:ActiveJob::Continuation] for usage. # module Continuable extend ActiveSupport::Concern diff --git a/activejob/lib/active_job/continuation.rb b/activejob/lib/active_job/continuation.rb index 9bc0c6f926421..6b2d5ba095cf3 100644 --- a/activejob/lib/active_job/continuation.rb +++ b/activejob/lib/active_job/continuation.rb @@ -161,11 +161,11 @@ module ActiveJob # === Configuration # # Continuable jobs have several configuration options: - # * :max_resumptions - The maximum number of times a job can be resumed. Defaults to +nil+ which means + # * :max_resumptions - The maximum number of times a job can be resumed. Defaults to +nil+ which means # unlimited resumptions. # * :resume_options - Options to pass to +retry_job+ when resuming the job. - # Defaults to +{ wait: 5.seconds }+. - # See +ActiveJob::Exceptions#retry_job+ for available options. + # Defaults to { wait: 5.seconds }. + # See {ActiveJob::Exceptions#retry_job}[rdoc-ref:ActiveJob::Exceptions#retry_job] for available options. # * :resume_errors_after_advancing - Whether to resume errors after advancing the continuation. # Defaults to +true+. class Continuation From 0f591639b864de6bf3fd760a6921f5eda17b2905 Mon Sep 17 00:00:00 2001 From: Ryuta Kamizono Date: Fri, 13 Jun 2025 18:21:27 +0900 Subject: [PATCH 0220/1075] Simplify condition checks --- .../connection_adapters/sqlite3/database_statements.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 aab5d3809e866..876f227dcdb9c 100644 --- a/activerecord/lib/active_record/connection_adapters/sqlite3/database_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/sqlite3/database_statements.rb @@ -104,7 +104,7 @@ def perform_query(raw_connection, sql, binds, type_casted_binds, prepare:, notif result = if stmt.column_count.zero? # No return stmt.step - affected_rows = if (raw_connection.total_changes - total_changes_before_query) > 0 + affected_rows = if raw_connection.total_changes > total_changes_before_query raw_connection.changes else 0 @@ -114,7 +114,7 @@ def perform_query(raw_connection, sql, binds, type_casted_binds, prepare:, notif else rows = stmt.to_a - affected_rows = if (raw_connection.total_changes - total_changes_before_query) > 0 + affected_rows = if raw_connection.total_changes > total_changes_before_query raw_connection.changes else 0 From 0ce44cde6b2833fab11dfc3b65831f6e34c8575b Mon Sep 17 00:00:00 2001 From: Ryan Murphy Date: Sat, 14 Jun 2025 16:33:33 -0400 Subject: [PATCH 0221/1075] Print the record count in the generator guide template override example --- guides/source/generators.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/guides/source/generators.md b/guides/source/generators.md index b70c9611089d4..5d35f824b1c73 100644 --- a/guides/source/generators.md +++ b/guides/source/generators.md @@ -246,7 +246,7 @@ To see this in action, let's create a `lib/templates/erb/scaffold/index.html.erb file with the following contents: ```erb -<%% @<%= plural_table_name %>.count %> <%= human_name.pluralize %> +<%%= @<%= plural_table_name %>.count %> <%= human_name.pluralize %> ``` Note that the template is an ERB template that renders _another_ ERB template. @@ -265,7 +265,7 @@ $ bin/rails generate scaffold Post title:string The contents of `app/views/posts/index.html.erb` is: ```erb -<% @posts.count %> Posts +<%= @posts.count %> Posts ``` [scaffold controller template]: https://github.com/rails/rails/blob/main/railties/lib/rails/generators/rails/scaffold_controller/templates/controller.rb.tt From 79a533804a9516b28b0570369594c172af03b1f7 Mon Sep 17 00:00:00 2001 From: Hartley McGuire Date: Sun, 15 Jun 2025 00:16:09 -0400 Subject: [PATCH 0222/1075] Fix `include?` on strict-loaded HABTM association Previously, trying to check whether a `new_record?` (`Post.new`) is `include?`d in a strict-loaded HABTM association would raise a `StrictLoadingViolationError` even when the HABTM association is already loaded. This commit fixes the issue by always using the `target` if the association is `loaded?` instead of querying the Through association (which is only needed when the association is dirty/has newly built records). --- .../active_record/associations/collection_association.rb | 6 +++--- activerecord/test/cases/strict_loading_test.rb | 8 ++++++++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/activerecord/lib/active_record/associations/collection_association.rb b/activerecord/lib/active_record/associations/collection_association.rb index bfef699e47657..ecbc0d3e58cad 100644 --- a/activerecord/lib/active_record/associations/collection_association.rb +++ b/activerecord/lib/active_record/associations/collection_association.rb @@ -259,10 +259,10 @@ def include?(record) klass = reflection.klass return false unless record.is_a?(klass) - if record.new_record? - include_in_memory?(record) - elsif loaded? + if loaded? target.include?(record) + elsif record.new_record? + include_in_memory?(record) else record_id = klass.composite_primary_key? ? klass.primary_key.zip(record.id).to_h : record.id scope.exists?(record_id) diff --git a/activerecord/test/cases/strict_loading_test.rb b/activerecord/test/cases/strict_loading_test.rb index e7b848e3bebde..d447f087b591f 100644 --- a/activerecord/test/cases/strict_loading_test.rb +++ b/activerecord/test/cases/strict_loading_test.rb @@ -636,6 +636,14 @@ def test_does_not_raise_on_eager_loading_a_habtm_relation_if_strict_loading_by_d end end + def test_does_not_raise_when_checking_if_new_record_included_in_eager_loaded_habtm_relation + Developer.first.projects << Project.first + + developer = Developer.includes(:projects).strict_loading.first + + assert_nothing_raised { developer.projects.include?(Project.new) } + end + def test_strict_loading_violation_raises_by_default assert_equal :raise, ActiveRecord.action_on_strict_loading_violation From 82e9029bbf63a33b69f007927979c5564a6afe9e Mon Sep 17 00:00:00 2001 From: Hartley McGuire Date: Mon, 16 Jun 2025 11:54:10 -0400 Subject: [PATCH 0223/1075] Add CHANGELOG [ci skip] (#55198) for 79a533804a9516b28b0570369594c172af03b1f7 --- activerecord/CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/activerecord/CHANGELOG.md b/activerecord/CHANGELOG.md index bacb4ee6a5741..bc89902c91420 100644 --- a/activerecord/CHANGELOG.md +++ b/activerecord/CHANGELOG.md @@ -1,3 +1,8 @@ +* Fix checking whether an unpersisted record is `include?`d in a strictly + loaded `has_and_belongs_to_many` association. + + *Hartley McGuire* + * Add ability to change transaction isolation for all pools within a block. This functionality is useful if your application needs to change the database From a890d401eb8896e1c07b84562bbe0fd354f7fa5a Mon Sep 17 00:00:00 2001 From: Jonathan Calvert Date: Tue, 17 Jun 2025 09:44:13 -0500 Subject: [PATCH 0224/1075] Use ntuples to populate row_count instead of count for Postgres PG::Result#ntuples uses a libpq function for getting the number of rows returned from a query, whereas #count is provided by Enumerable and thus iterates through the entire result set. --- activerecord/CHANGELOG.md | 4 ++++ .../connection_adapters/postgresql/database_statements.rb | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/activerecord/CHANGELOG.md b/activerecord/CHANGELOG.md index bc89902c91420..3827ab70f6e2f 100644 --- a/activerecord/CHANGELOG.md +++ b/activerecord/CHANGELOG.md @@ -1,3 +1,7 @@ +* Use ntuples to populate row_count instead of count for Postgres + + *Jonathan Calvert* + * Fix checking whether an unpersisted record is `include?`d in a strictly loaded `has_and_belongs_to_many` association. 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 6d07c2f73273d..46acf105be402 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/database_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/database_statements.rb @@ -165,7 +165,7 @@ def perform_query(raw_connection, sql, binds, type_casted_binds, prepare:, notif verified! notification_payload[:affected_rows] = result.cmd_tuples - notification_payload[:row_count] = result.count + notification_payload[:row_count] = result.ntuples result end From 870d108fe6226df622e88b726855d7e1b816d8e9 Mon Sep 17 00:00:00 2001 From: heka1024 Date: Wed, 18 Jun 2025 02:13:58 +0900 Subject: [PATCH 0225/1075] Translate `Trilogy::SSLError` to `ActiveRecord::ConnectionFailed` Fixes #55126 --- .../lib/active_record/connection_adapters/trilogy_adapter.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/activerecord/lib/active_record/connection_adapters/trilogy_adapter.rb b/activerecord/lib/active_record/connection_adapters/trilogy_adapter.rb index d3e8d60669fd2..66e9547729060 100644 --- a/activerecord/lib/active_record/connection_adapters/trilogy_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/trilogy_adapter.rb @@ -181,7 +181,7 @@ def translate_exception(exception, message:, sql:, binds:) end case exception - when ::Trilogy::ConnectionClosed, ::Trilogy::EOFError + when ::Trilogy::ConnectionClosed, ::Trilogy::EOFError, ::Trilogy::SSLError return ConnectionFailed.new(message, connection_pool: @pool) when ::Trilogy::Error if exception.is_a?(SystemCallError) || exception.message.include?("TRILOGY_INVALID_SEQUENCE_ID") From d8c4281a71f0647051705c509f8ba53d7956ba0f Mon Sep 17 00:00:00 2001 From: Petrik Date: Wed, 18 Jun 2025 10:44:12 +0200 Subject: [PATCH 0226/1075] Remove documentation for non existing ColumnMethods#column [ci skip] The `column` method is defined on `TableDefinition` not on `ColumnMethods`. --- .../abstract/schema_definitions.rb | 11 ----------- 1 file changed, 11 deletions(-) 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 85b65af17c01d..7fa20c90845cf 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/schema_definitions.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/schema_definitions.rb @@ -328,17 +328,6 @@ def primary_key(name, type = :primary_key, **options) column(name, type, **options, primary_key: true) end - ## - # :method: column - # :call-seq: column(name, type, **options) - # - # Appends a column or columns of a specified type. - # - # t.string(:goat) - # t.string(:goat, :sheep) - # - # See TableDefinition#column - define_column_methods :bigint, :binary, :boolean, :date, :datetime, :decimal, :float, :integer, :json, :string, :text, :time, :timestamp, :virtual From a3186ca5d13e6c3ad1c120b09bf98abccebcd176 Mon Sep 17 00:00:00 2001 From: Yuhi-Sato Date: Fri, 20 Jun 2025 12:34:17 +0900 Subject: [PATCH 0227/1075] Fix typo in with_recursive example --- activerecord/lib/active_record/relation/query_methods.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/activerecord/lib/active_record/relation/query_methods.rb b/activerecord/lib/active_record/relation/query_methods.rb index feba4e73116b4..035ba6cd5975a 100644 --- a/activerecord/lib/active_record/relation/query_methods.rb +++ b/activerecord/lib/active_record/relation/query_methods.rb @@ -510,7 +510,7 @@ def with!(*args) # :nodoc: # # WITH RECURSIVE post_and_replies AS ( # # (SELECT * FROM posts WHERE id = 42) # # UNION ALL - # # (SELECT * FROM posts JOIN posts_and_replies ON posts.in_reply_to_id = posts_and_replies.id) + # # (SELECT * FROM posts JOIN post_and_replies ON posts.in_reply_to_id = post_and_replies.id) # # ) # # SELECT * FROM posts # From d67b83e3cdb95b313cd47085375a5c2b9f0feea0 Mon Sep 17 00:00:00 2001 From: Jarrett Lusso Date: Fri, 20 Jun 2025 13:35:06 -0400 Subject: [PATCH 0228/1075] Respect users configured `IRB.conf[:IRB_NAME]` If a user has configured `IRB_NAME` in their IRB configuration, Rails is overriding it. This PR makes it so the `IRB_NAME` is only set if it is not the default, similar to the way the `PROMPT_MODE` is set. --- .../lib/rails/commands/console/irb_console.rb | 3 ++- railties/test/application/console_test.rb | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/railties/lib/rails/commands/console/irb_console.rb b/railties/lib/rails/commands/console/irb_console.rb index a06abcaf8b705..dcebf096de2ad 100644 --- a/railties/lib/rails/commands/console/irb_console.rb +++ b/railties/lib/rails/commands/console/irb_console.rb @@ -87,7 +87,8 @@ def start env = colorized_env prompt_prefix = "%N(#{env})" - IRB.conf[:IRB_NAME] = @app.name + # Respect user's configured irb name. + IRB.conf[:IRB_NAME] = @app.name if IRB.conf[:IRB_NAME] == "irb" IRB.conf[:PROMPT][:RAILS_PROMPT] = { PROMPT_I: "#{prompt_prefix}:%03n> ", diff --git a/railties/test/application/console_test.rb b/railties/test/application/console_test.rb index 3c69cfc872122..a7125f06cdd57 100644 --- a/railties/test/application/console_test.rb +++ b/railties/test/application/console_test.rb @@ -242,6 +242,21 @@ class User write_prompt "User.new.respond_to?(:age)", "=> true" end + def test_console_respects_user_defined_irb_name + irbrc = Tempfile.new("irbrc") + irbrc.write <<-RUBY + IRB.conf[:IRB_NAME] = "jarretts-irb" + RUBY + irbrc.close + + options = "-e test" + spawn_console(options, env: { "IRBRC" => irbrc.path }) + + write_prompt "123", prompt: "jarretts-irb(test):002> " + ensure + File.unlink(irbrc) + end + def test_console_respects_user_defined_prompt_mode irbrc = Tempfile.new("irbrc") irbrc.write <<-RUBY From 7286208d60961949ea1a778a18af087c8534c9f1 Mon Sep 17 00:00:00 2001 From: Andrey Sobolev Date: Sat, 21 Jun 2025 12:33:18 +0300 Subject: [PATCH 0229/1075] Fix typos in Active Record Associations guide --- guides/source/association_basics.md | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/guides/source/association_basics.md b/guides/source/association_basics.md index 2017386d76717..73dd2c5552ed9 100644 --- a/guides/source/association_basics.md +++ b/guides/source/association_basics.md @@ -217,7 +217,7 @@ then you should use `has_one` instead. When used alone, `belongs_to` produces a one-directional one-to-one relationship. Therefore each book in the above example "knows" its author, but -the authors don't know about their books. To setup a [bi-directional +the authors don't know about their books. To set up a [bi-directional association](#bi-directional-associations) - use `belongs_to` in combination with a `has_one` or `has_many` on the other model, in this case the Author model. @@ -560,7 +560,7 @@ the associated object's foreign key to the same value. The `build_association` method returns a new object of the associated type. This object will be instantiated from the passed attributes, and the link through -this objects foreign key will be set, but the associated object will _not_ yet +this object's foreign key will be set, but the associated object will _not_ yet be saved. ```ruby @@ -1614,7 +1614,7 @@ Similarly, you can retrieve a collection of pictures from an instance of the Additionally, if you have an instance of the `Picture` model, you can get its parent via `@picture.imageable`, which could be an `Employee` or a `Product`. -To setup a polymorphic association manually you would need to declare both a +To set up a polymorphic association manually you would need to declare both a foreign key column (`imageable_id`) and a type column (`imageable_type`) in the model: @@ -1884,7 +1884,7 @@ end class Car < Vehicle end -Car.create +Car.create(color: "Red", price: 10000) # => # ``` @@ -1905,7 +1905,7 @@ class Vehicle < ApplicationRecord self.inheritance_column = nil end -Vehicle.create!(type: "Car") +Vehicle.create!(type: "Car", color: "Red", price: 10000) # => # ``` @@ -1929,8 +1929,7 @@ includes all attributes of all subclasses in a single table. A disadvantage of this approach is that it can result in table bloat, as the table will include attributes specific to each subclass, even if they aren't -used by others. This can be solved by using [`Delegated -Types`](#delegated-types). +used by others. This can be solved by using [`Delegated Types`](#delegated-types). Additionally, if you’re using [polymorphic associations](#polymorphic-associations), where a model can belong to more than From ea66b1c750fd2984167382039d77802930865712 Mon Sep 17 00:00:00 2001 From: Xavier Noria Date: Sat, 21 Jun 2025 23:15:44 +0200 Subject: [PATCH 0230/1075] Implement ActiveSupport::BacktraceCleaner#first_clean_frame --- activejob/lib/active_job/log_subscriber.rb | 6 +-- .../lib/active_record/log_subscriber.rb | 6 +-- activerecord/lib/active_record/query_logs.rb | 6 +-- activesupport/CHANGELOG.md | 6 +++ .../lib/active_support/backtrace_cleaner.rb | 26 +++++++++++ activesupport/test/backtrace_cleaner_test.rb | 44 +++++++++++++++++++ 6 files changed, 79 insertions(+), 15 deletions(-) diff --git a/activejob/lib/active_job/log_subscriber.rb b/activejob/lib/active_job/log_subscriber.rb index 3f64893b798f9..eda0ae60ccccd 100644 --- a/activejob/lib/active_job/log_subscriber.rb +++ b/activejob/lib/active_job/log_subscriber.rb @@ -256,11 +256,7 @@ def log_enqueue_source end def enqueue_source_location - Thread.each_caller_location do |location| - frame = backtrace_cleaner.clean_frame(location) - return frame if frame - end - nil + backtrace_cleaner.first_clean_frame end def enqueued_jobs_message(adapter, enqueued_jobs) diff --git a/activerecord/lib/active_record/log_subscriber.rb b/activerecord/lib/active_record/log_subscriber.rb index fbb75d7306767..a76dde1ba4460 100644 --- a/activerecord/lib/active_record/log_subscriber.rb +++ b/activerecord/lib/active_record/log_subscriber.rb @@ -127,11 +127,7 @@ def log_query_source end def query_source_location - Thread.each_caller_location do |location| - frame = backtrace_cleaner.clean_frame(location) - return frame if frame - end - nil + backtrace_cleaner.first_clean_frame end def filter(name, value) diff --git a/activerecord/lib/active_record/query_logs.rb b/activerecord/lib/active_record/query_logs.rb index 2b06340eeeb8c..513c6059e9089 100644 --- a/activerecord/lib/active_record/query_logs.rb +++ b/activerecord/lib/active_record/query_logs.rb @@ -153,11 +153,7 @@ def clear_cache # :nodoc: end def query_source_location # :nodoc: - Thread.each_caller_location do |location| - frame = LogSubscriber.backtrace_cleaner.clean_frame(location) - return frame if frame - end - nil + LogSubscriber.backtrace_cleaner.first_clean_frame end ActiveSupport::ExecutionContext.after_change { ActiveRecord::QueryLogs.clear_cache } diff --git a/activesupport/CHANGELOG.md b/activesupport/CHANGELOG.md index 279722ed12e5d..1397ebbf3a90f 100644 --- a/activesupport/CHANGELOG.md +++ b/activesupport/CHANGELOG.md @@ -1,3 +1,9 @@ +* The new method `ActiveSupport::BacktraceCleaner#first_clean_frame` returns + the first clean frame of the caller's backtrace, or `nil`. Useful when you + want to report the application-level location where something happened. + + *Xavier Noria* + * Always clear `CurrentAttribute` instances. Previously `CurrentAttribute` instance would be reset at the end of requests. diff --git a/activesupport/lib/active_support/backtrace_cleaner.rb b/activesupport/lib/active_support/backtrace_cleaner.rb index 0b9ec3461d288..755b3e4194b33 100644 --- a/activesupport/lib/active_support/backtrace_cleaner.rb +++ b/activesupport/lib/active_support/backtrace_cleaner.rb @@ -74,6 +74,32 @@ def clean_frame(frame, kind = :silent) end end + # Thread.each_caller_location does not accept a start in Ruby < 3.4. + if Thread.method(:each_caller_location).arity == 0 + # Returns the first clean frame of the caller's backtrace, or +nil+. + def first_clean_frame(kind = :silent) + caller_location_skipped = false + + Thread.each_caller_location do |location| + unless caller_location_skipped + caller_location_skipped = true + next + end + + frame = clean_frame(location, kind) + return frame if frame + end + end + else + # Returns the first clean frame of the caller's backtrace, or +nil+. + def first_clean_frame(kind = :silent) + Thread.each_caller_location(2) do |location| + frame = clean_frame(location, kind) + return frame if frame + end + end + end + # Adds a filter from the block provided. Each line in the backtrace will be # mapped against this filter. # diff --git a/activesupport/test/backtrace_cleaner_test.rb b/activesupport/test/backtrace_cleaner_test.rb index d05035413d671..3bb85a9e848be 100644 --- a/activesupport/test/backtrace_cleaner_test.rb +++ b/activesupport/test/backtrace_cleaner_test.rb @@ -136,3 +136,47 @@ def setup assert_equal backtrace, @bc.clean(backtrace) end end + +class BacktraceCleanerFirstCleanFrame < ActiveSupport::TestCase + def setup + @bc = ActiveSupport::BacktraceCleaner.new + end + + def invoke_first_clean_frame_defaults + -> do + @bc.first_clean_frame.tap { @line = __LINE__ + 1 } + end.call + end + + def invoke_first_clean_frame(kind = :silent) + -> do + @bc.first_clean_frame(kind).tap { @line = __LINE__ + 1 } + end.call + end + + test "returns the first clean frame (defaults)" do + result = invoke_first_clean_frame_defaults + assert_match(/\A#{__FILE__}:#@line:in [`'](#{self.class}#)?invoke_first_clean_frame_defaults[`']\z/, result) + end + + test "returns the first clean frame (:silent)" do + result = invoke_first_clean_frame(:silent) + assert_match(/\A#{__FILE__}:#@line:in [`'](#{self.class}#)?invoke_first_clean_frame[`']\z/, result) + end + + test "returns the first clean frame (:noise)" do + @bc.add_silencer { true } + result = invoke_first_clean_frame(:noise) + assert_match(/\A#{__FILE__}:#@line:in [`'](#{self.class}#)?invoke_first_clean_frame[`']\z/, result) + end + + test "returns the first clean frame (:any)" do + result = invoke_first_clean_frame(:any) # fallback of the case statement + assert_match(/\A#{__FILE__}:#@line:in [`'](#{self.class}#)?invoke_first_clean_frame[`']\z/, result) + end + + test "returns nil if there is no clean frame" do + @bc.add_silencer { true } + assert_nil invoke_first_clean_frame_defaults + end +end From be7f922d7d5c1a1057180c4d17b3b8950602ee5f Mon Sep 17 00:00:00 2001 From: zzak Date: Wed, 18 Jun 2025 08:15:25 +0900 Subject: [PATCH 0231/1075] Ensure yarn and bun version fallback If we allow the `version` methods to return nil, it's possible to end up in a situation where Dockerfile contains something like ``` ARG BUN_VERSION= ``` This can occur when bun or yarn are not installed locally on the current version of node, and using a version manager like `nodenv`. In this case, `rails new` would previously raise an error: ``` nodenv: yarn: command not found The `yarn' command exists in these Node versions: 20.15.0 E Error: AppGeneratorTest#test_css_option_with_cssbundling_gem_does_not_force_jsbundling_gem: NoMethodError: undefined method '>=' for nil lib/rails/generators/app_base.rb:550:in 'Rails::Generators::AppBase#yarn_through_corepack?' lib/rails/generators/rails/app/templates/Dockerfile.tt:41:in 'Thor::Actions#template' ``` By ensuring we fallback to a non-nil value we can ensure this case doesn't occur.
Full example: ``` $ bundle exec railties/exe/rails new ~/code/apps/TestApp_EsBuild_Tailwind_Pg -j=esbuild -c=tailwind -d=postgresql --dev nodenv: yarn: command not found The `yarn' command exists in these Node versions: 20.15.0 nodenv: yarn: command not found The `yarn' command exists in these Node versions: 20.15.0 nodenv: yarn: command not found The `yarn' command exists in these Node versions: 20.15.0 bundler: failed to load command: railties/exe/rails (railties/exe/rails) /home/zzak/code/rails/railties/lib/rails/generators/app_base.rb:550:in 'Rails::Generators::AppBase#yarn_through_corepack?': undefined method '>=' for nil (NoMethodError) dockerfile_yarn_version >= "2" ^^ from /home/zzak/code/rails/railties/lib/rails/generators/rails/app/templates/Dockerfile.tt:41:in 'Thor::Actions#template' from /home/zzak/.rbenv/versions/3.4.4/lib/ruby/3.4.0/erb.rb:429:in 'Kernel#eval' from /home/zzak/.rbenv/versions/3.4.4/lib/ruby/3.4.0/erb.rb:429:in 'ERB#result' from /home/zzak/.rbenv/versions/3.4.4/lib/ruby/gems/3.4.0/gems/thor-1.3.2/lib/thor/actions/file_manipulation.rb:128:in 'block in Thor::Actions#template' from /home/zzak/.rbenv/versions/3.4.4/lib/ruby/gems/3.4.0/gems/thor-1.3.2/lib/thor/actions/create_file.rb:54:in 'Thor::Actions::CreateFile#render' from /home/zzak/.rbenv/versions/3.4.4/lib/ruby/gems/3.4.0/gems/thor-1.3.2/lib/thor/actions/create_file.rb:64:in 'block (2 levels) in Thor::Actions::CreateFile#invoke!' from /home/zzak/.rbenv/versions/3.4.4/lib/ruby/gems/3.4.0/gems/thor-1.3.2/lib/thor/actions/create_file.rb:64:in 'IO.open' from /home/zzak/.rbenv/versions/3.4.4/lib/ruby/gems/3.4.0/gems/thor-1.3.2/lib/thor/actions/create_file.rb:64:in 'block in Thor::Actions::CreateFile#invoke!' from /home/zzak/.rbenv/versions/3.4.4/lib/ruby/gems/3.4.0/gems/thor-1.3.2/lib/thor/actions/empty_directory.rb:117:in 'Thor::Actions::EmptyDirectory#invoke_with_conflict_check' from /home/zzak/.rbenv/versions/3.4.4/lib/ruby/gems/3.4.0/gems/thor-1.3.2/lib/thor/actions/create_file.rb:61:in 'Thor::Actions::CreateFile#invoke!' from /home/zzak/.rbenv/versions/3.4.4/lib/ruby/gems/3.4.0/gems/thor-1.3.2/lib/thor/actions.rb:93:in 'Thor::Actions#action' from /home/zzak/.rbenv/versions/3.4.4/lib/ruby/gems/3.4.0/gems/thor-1.3.2/lib/thor/actions/create_file.rb:25:in 'Thor::Actions#create_file' from /home/zzak/.rbenv/versions/3.4.4/lib/ruby/gems/3.4.0/gems/thor-1.3.2/lib/thor/actions/file_manipulation.rb:124:in 'Thor::Actions#template' from /home/zzak/code/rails/railties/lib/rails/generators/rails/app/app_generator.rb:20:in 'Rails::ActionMethods#template' from /home/zzak/code/rails/railties/lib/rails/generators/rails/app/app_generator.rb:79:in 'Rails::AppBuilder#dockerfiles' from /home/zzak/code/rails/railties/lib/rails/generators/app_base.rb:174:in 'Kernel#public_send' from /home/zzak/code/rails/railties/lib/rails/generators/app_base.rb:174:in 'Rails::Generators::AppBase#build' from /home/zzak/code/rails/railties/lib/rails/generators/rails/app/app_generator.rb:402:in 'Rails::Generators::AppGenerator#create_dockerfiles' from /home/zzak/.rbenv/versions/3.4.4/lib/ruby/gems/3.4.0/gems/thor-1.3.2/lib/thor/command.rb:28:in 'Thor::Command#run' from /home/zzak/.rbenv/versions/3.4.4/lib/ruby/gems/3.4.0/gems/thor-1.3.2/lib/thor/invocation.rb:127:in 'Thor::Invocation#invoke_command' from /home/zzak/.rbenv/versions/3.4.4/lib/ruby/gems/3.4.0/gems/thor-1.3.2/lib/thor/invocation.rb:134:in 'block in Thor::Invocation#invoke_all' from /home/zzak/.rbenv/versions/3.4.4/lib/ruby/gems/3.4.0/gems/thor-1.3.2/lib/thor/invocation.rb:134:in 'Hash#each' from /home/zzak/.rbenv/versions/3.4.4/lib/ruby/gems/3.4.0/gems/thor-1.3.2/lib/thor/invocation.rb:134:in 'Enumerable#map' from /home/zzak/.rbenv/versions/3.4.4/lib/ruby/gems/3.4.0/gems/thor-1.3.2/lib/thor/invocation.rb:134:in 'Thor::Invocation#invoke_all' from /home/zzak/.rbenv/versions/3.4.4/lib/ruby/gems/3.4.0/gems/thor-1.3.2/lib/thor/group.rb:243:in 'Thor::Group.dispatch' from /home/zzak/.rbenv/versions/3.4.4/lib/ruby/gems/3.4.0/gems/thor-1.3.2/lib/thor/base.rb:584:in 'Thor::Base::ClassMethods#start' from /home/zzak/code/rails/railties/lib/rails/commands/application/application_command.rb:28:in 'Rails::Command::ApplicationCommand#perform' from /home/zzak/.rbenv/versions/3.4.4/lib/ruby/gems/3.4.0/gems/thor-1.3.2/lib/thor/command.rb:28:in 'Thor::Command#run' from /home/zzak/.rbenv/versions/3.4.4/lib/ruby/gems/3.4.0/gems/thor-1.3.2/lib/thor/invocation.rb:127:in 'Thor::Invocation#invoke_command' from /home/zzak/code/rails/railties/lib/rails/command/base.rb:176:in 'Rails::Command::Base#invoke_command' from /home/zzak/.rbenv/versions/3.4.4/lib/ruby/gems/3.4.0/gems/thor-1.3.2/lib/thor.rb:538:in 'Thor.dispatch' from /home/zzak/code/rails/railties/lib/rails/command/base.rb:71:in 'Rails::Command::Base.perform' from /home/zzak/code/rails/railties/lib/rails/command.rb:65:in 'block in Rails::Command.invoke' from /home/zzak/code/rails/railties/lib/rails/command.rb:143:in 'Rails::Command.with_argv' from /home/zzak/code/rails/railties/lib/rails/command.rb:63:in 'Rails::Command.invoke' from /home/zzak/code/rails/railties/lib/rails/cli.rb:20:in '' from /home/zzak/.rbenv/versions/3.4.4/lib/ruby/3.4.0/bundled_gems.rb:82:in 'Kernel.require' from /home/zzak/.rbenv/versions/3.4.4/lib/ruby/3.4.0/bundled_gems.rb:82:in 'block (2 levels) in Kernel#replace_require' from railties/exe/rails:10:in '' from /home/zzak/.rbenv/versions/3.4.4/lib/ruby/gems/3.4.0/gems/bundler-2.6.2/lib/bundler/cli/exec.rb:59:in 'Kernel.load' from /home/zzak/.rbenv/versions/3.4.4/lib/ruby/gems/3.4.0/gems/bundler-2.6.2/lib/bundler/cli/exec.rb:59:in 'Bundler::CLI::Exec#kernel_load' from /home/zzak/.rbenv/versions/3.4.4/lib/ruby/gems/3.4.0/gems/bundler-2.6.2/lib/bundler/cli/exec.rb:23:in 'Bundler::CLI::Exec#run' from /home/zzak/.rbenv/versions/3.4.4/lib/ruby/gems/3.4.0/gems/bundler-2.6.2/lib/bundler/cli.rb:452:in 'Bundler::CLI#exec' from /home/zzak/.rbenv/versions/3.4.4/lib/ruby/gems/3.4.0/gems/bundler-2.6.2/lib/bundler/vendor/thor/lib/thor/command.rb:28:in 'Bundler::Thor::Command#run' from /home/zzak/.rbenv/versions/3.4.4/lib/ruby/gems/3.4.0/gems/bundler-2.6.2/lib/bundler/vendor/thor/lib/thor/invocation.rb:127:in 'Bundler::Thor::Invocation#invoke_command' from /home/zzak/.rbenv/versions/3.4.4/lib/ruby/gems/3.4.0/gems/bundler-2.6.2/lib/bundler/vendor/thor/lib/thor.rb:538:in 'Bundler::Thor.dispatch' from /home/zzak/.rbenv/versions/3.4.4/lib/ruby/gems/3.4.0/gems/bundler-2.6.2/lib/bundler/cli.rb:35:in 'Bundler::CLI.dispatch' from /home/zzak/.rbenv/versions/3.4.4/lib/ruby/gems/3.4.0/gems/bundler-2.6.2/lib/bundler/vendor/thor/lib/thor/base.rb:584:in 'Bundler::Thor::Base::ClassMethods#start' from /home/zzak/.rbenv/versions/3.4.4/lib/ruby/gems/3.4.0/gems/bundler-2.6.2/lib/bundler/cli.rb:29:in 'Bundler::CLI.start' from /home/zzak/.rbenv/versions/3.4.4/lib/ruby/gems/3.4.0/gems/bundler-2.6.2/exe/bundle:28:in 'block in ' from /home/zzak/.rbenv/versions/3.4.4/lib/ruby/gems/3.4.0/gems/bundler-2.6.2/lib/bundler/friendly_errors.rb:117:in 'Bundler.with_friendly_errors' from /home/zzak/.rbenv/versions/3.4.4/lib/ruby/gems/3.4.0/gems/bundler-2.6.2/exe/bundle:20:in '' from /home/zzak/.rbenv/versions/3.4.4/bin/bundle:25:in 'Kernel#load' from /home/zzak/.rbenv/versions/3.4.4/bin/bundle:25:in '
' [ble: exit 1] ```
--- railties/lib/rails/generators/app_base.rb | 23 ++++++++----- .../test/generators/app_generator_test.rb | 33 +++++++++++++++++++ 2 files changed, 48 insertions(+), 8 deletions(-) diff --git a/railties/lib/rails/generators/app_base.rb b/railties/lib/rails/generators/app_base.rb index c2e6b2fcf4607..708a78f35403c 100644 --- a/railties/lib/rails/generators/app_base.rb +++ b/railties/lib/rails/generators/app_base.rb @@ -540,20 +540,27 @@ def node_version end def dockerfile_yarn_version - using_node? and `yarn --version`[/\d+\.\d+\.\d+/] - rescue - "latest" + version = begin + `yarn --version`[/\d+\.\d+\.\d+/] + rescue + nil + end + + version || "latest" end def yarn_through_corepack? - true if dockerfile_yarn_version == "latest" - dockerfile_yarn_version >= "2" + using_node? and "#{dockerfile_yarn_version}" >= "2" end def dockerfile_bun_version - using_bun? and `bun --version`[/\d+\.\d+\.\d+/] - rescue - BUN_VERSION + version = begin + `bun --version`[/\d+\.\d+\.\d+/] + rescue + nil + end + + version || BUN_VERSION end def dockerfile_binfile_fixups diff --git a/railties/test/generators/app_generator_test.rb b/railties/test/generators/app_generator_test.rb index d8251374e5b9e..bd0bdd6bd882d 100644 --- a/railties/test/generators/app_generator_test.rb +++ b/railties/test/generators/app_generator_test.rb @@ -925,6 +925,22 @@ def test_esbuild_option_with_js_argument assert_gem "jsbundling-rails" end + def test_esbuild_without_yarn_installed + generator([destination_root], javascript: "esbuild") + + # fallback to latest when yarn is not installed + generator.stub :dockerfile_yarn_version, "latest" do + quietly { generator.invoke_all } + end + + assert_gem "jsbundling-rails" + assert_file "Dockerfile" do |content| + assert_match(/ARG YARN_VERSION=latest/, content) + + assert_match("RUN corepack enable && yarn set version $YARN_VERSION", content) + end + end + def test_bun_option generator([destination_root], javascript: "bun") @@ -949,6 +965,23 @@ def test_bun_option_with_js_argument assert_gem "jsbundling-rails" end + def test_bun_without_bun_installed + generator([destination_root], javascript: "bun") + bun_version = generator.class.const_get(:BUN_VERSION) + + # fallback to constant when bun is not installed + generator.stub :dockerfile_bun_version, bun_version do + quietly { generator.invoke_all } + end + + assert_gem "jsbundling-rails" + assert_file "Dockerfile" do |content| + assert_match(/ARG BUN_VERSION=#{bun_version}/, content) + + assert_match("RUN bun install --frozen-lockfile", content) + end + end + def test_skip_javascript_option_with_skip_javascript_argument run_generator [destination_root, "--skip-javascript"] assert_no_gem "stimulus-rails" From bf6cd8f2290dc48d77f5223555a7706bcd76e1c4 Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Sun, 22 Jun 2025 11:00:41 +0100 Subject: [PATCH 0232/1075] Refactor Generators::AppBase to avoid blind rescues --- railties/lib/rails/generators/app_base.rb | 26 ++++++++--------------- 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/railties/lib/rails/generators/app_base.rb b/railties/lib/rails/generators/app_base.rb index 708a78f35403c..d3bd0c015f8de 100644 --- a/railties/lib/rails/generators/app_base.rb +++ b/railties/lib/rails/generators/app_base.rb @@ -529,24 +529,22 @@ def using_bun? using_js_runtime? && %w[bun].include?(options[:javascript]) end + def capture_command(command, pattern) + `#{command}`[pattern] + rescue SystemCallError + nil + end + def node_version if using_node? ENV.fetch("NODE_VERSION") do - `node --version`[/\d+\.\d+\.\d+/] - rescue - NODE_LTS_VERSION + capture_command("node --version", /\d+\.\d+\.\d+/) || NODE_LTS_VERSION end end end def dockerfile_yarn_version - version = begin - `yarn --version`[/\d+\.\d+\.\d+/] - rescue - nil - end - - version || "latest" + capture_command("yarn --version", /\d+\.\d+\.\d+/) || "latest" end def yarn_through_corepack? @@ -554,13 +552,7 @@ def yarn_through_corepack? end def dockerfile_bun_version - version = begin - `bun --version`[/\d+\.\d+\.\d+/] - rescue - nil - end - - version || BUN_VERSION + capture_command("bun --version", /\d+\.\d+\.\d+/) || BUN_VERSION end def dockerfile_binfile_fixups From 2fe62541b1590be55ec262e9e08dcfcd3ed46cb7 Mon Sep 17 00:00:00 2001 From: OuYangJinTing Date: Wed, 18 Jun 2025 11:38:49 +0800 Subject: [PATCH 0233/1075] Prevent the `stream_from` method throwing RuntimeError(can't add a new key into hash during iteration) --- actioncable/lib/action_cable/channel/base.rb | 6 +++ .../lib/action_cable/channel/streams.rb | 2 + actioncable/test/channel/base_test.rb | 10 ++++ actioncable/test/channel/stream_test.rb | 54 +++++++++++++++++++ actioncable/test/stubs/test_adapter.rb | 1 + 5 files changed, 73 insertions(+) diff --git a/actioncable/lib/action_cable/channel/base.rb b/actioncable/lib/action_cable/channel/base.rb index 6b0ff57e3109e..89c678c712e4e 100644 --- a/actioncable/lib/action_cable/channel/base.rb +++ b/actioncable/lib/action_cable/channel/base.rb @@ -165,6 +165,7 @@ def initialize(connection, identifier, params = {}) @reject_subscription = nil @subscription_confirmation_sent = nil + @unsubscribed = false delegate_connection_identifiers end @@ -200,11 +201,16 @@ def subscribe_to_channel # cleanup with callbacks. This method is not intended to be called directly by # the user. Instead, override the #unsubscribed callback. def unsubscribe_from_channel # :nodoc: + @unsubscribed = true run_callbacks :unsubscribe do unsubscribed end end + def unsubscribed? # :nodoc: + @unsubscribed + end + private # Called once a consumer has become a subscriber of the channel. Usually the # place to set up any streams you want this channel to be sending to the diff --git a/actioncable/lib/action_cable/channel/streams.rb b/actioncable/lib/action_cable/channel/streams.rb index 7ab3ab20d7a2f..bb909b538c116 100644 --- a/actioncable/lib/action_cable/channel/streams.rb +++ b/actioncable/lib/action_cable/channel/streams.rb @@ -88,6 +88,8 @@ module Streams # callback. Defaults to `coder: nil` which does no decoding, passes raw # messages. def stream_from(broadcasting, callback = nil, coder: nil, &block) + return if unsubscribed? + broadcasting = String(broadcasting) # Don't send the confirmation until pubsub#subscribe is successful diff --git a/actioncable/test/channel/base_test.rb b/actioncable/test/channel/base_test.rb index 4394338d38a25..62e6958fcd8d0 100644 --- a/actioncable/test/channel/base_test.rb +++ b/actioncable/test/channel/base_test.rb @@ -126,6 +126,16 @@ def error_handler assert_not_predicate @channel, :subscribed? end + test "unsubscribed? method returns correct status" do + assert_not @channel.unsubscribed? + + @channel.subscribe_to_channel + assert_not @channel.unsubscribed? + + @channel.unsubscribe_from_channel + assert @channel.unsubscribed? + end + test "connection identifiers" do assert_equal @user.name, @channel.current_user.name end diff --git a/actioncable/test/channel/stream_test.rb b/actioncable/test/channel/stream_test.rb index 6b520695e5f0e..06454e9efc651 100644 --- a/actioncable/test/channel/stream_test.rb +++ b/actioncable/test/channel/stream_test.rb @@ -4,6 +4,7 @@ require "minitest/mock" require "stubs/test_connection" require "stubs/room" +require "concurrent/atomic/cyclic_barrier" module ActionCable::StreamTests class Connection < ActionCable::Connection::Base @@ -280,6 +281,59 @@ class StreamTest < ActionCable::TestCase end end + test "concurrent unsubscribe_from_channel and stream_from do not raise RuntimeError" do + ENV["UNSUBSCRIBE_SLEEP_TIME"] = "0.0001" # Set a delay to increase the chance of concurrent execution + run_in_eventmachine do + connection = TestConnection.new + channel = ChatChannel.new connection, "{id: 1}", id: 1 + channel.subscribe_to_channel + + # Set up initial streams + channel.stream_from "room_one" + channel.stream_from "room_two" + wait_for_async + + # Create barriers to synchronize thread execution + barrier = Concurrent::CyclicBarrier.new(2) + + exception_caught = nil + + # Thread 1: calls unsubscribe_from_channel + thread1 = Thread.new do + barrier.wait + # Add a small delay to increase the chance of concurrent execution + sleep 0.001 + channel.unsubscribe_from_channel + rescue => e + exception_caught = e + ensure + barrier.wait + end + + # Thread 2: calls stream_from during unsubscribe_from_channel iteration + thread2 = Thread.new do + barrier.wait + # Try to add streams while unsubscribe_from_channel is potentially iterating + 10.times do |i| + channel.stream_from "concurrent_room_#{i}" + sleep 0.0001 # Small delay to interleave with unsubscribe_from_channel + end + rescue => e + exception_caught = e + ensure + barrier.wait + end + + thread1.join + thread2.join + + # Ensure no RuntimeError was raised during concurrent access + assert_nil exception_caught, "Concurrent unsubscribe_from_channel and stream_from should not raise RuntimeError: #{exception_caught}" + end + ensure + ENV.delete("UNSUBSCRIBE_SLEEP_TIME") + end + private def subscribers_of(connection) connection diff --git a/actioncable/test/stubs/test_adapter.rb b/actioncable/test/stubs/test_adapter.rb index 22822acdffaa5..86bcbd54a2ac4 100644 --- a/actioncable/test/stubs/test_adapter.rb +++ b/actioncable/test/stubs/test_adapter.rb @@ -10,6 +10,7 @@ def subscribe(channel, callback, success_callback = nil) end def unsubscribe(channel, callback) + sleep ENV["UNSUBSCRIBE_SLEEP_TIME"].to_f if ENV["UNSUBSCRIBE_SLEEP_TIME"] subscriber_map[channel].delete(callback) subscriber_map.delete(channel) if subscriber_map[channel].empty? @@unsubscribe_called = { channel: channel, callback: callback } From c8f71d5830e3c6b8ca57291f92cbef2de3c811bf Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Sun, 22 Jun 2025 11:32:12 +0100 Subject: [PATCH 0234/1075] Improve concurrent unsubscribe_from_channel test --- actioncable/test/channel/stream_test.rb | 8 ++++++-- actioncable/test/stubs/test_adapter.rb | 9 ++++++++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/actioncable/test/channel/stream_test.rb b/actioncable/test/channel/stream_test.rb index 06454e9efc651..3aa42297d68ff 100644 --- a/actioncable/test/channel/stream_test.rb +++ b/actioncable/test/channel/stream_test.rb @@ -282,9 +282,11 @@ class StreamTest < ActionCable::TestCase end test "concurrent unsubscribe_from_channel and stream_from do not raise RuntimeError" do - ENV["UNSUBSCRIBE_SLEEP_TIME"] = "0.0001" # Set a delay to increase the chance of concurrent execution + threads = [] run_in_eventmachine do connection = TestConnection.new + connection.pubsub.unsubscribe_latency = 0.1 + channel = ChatChannel.new connection, "{id: 1}", id: 1 channel.subscribe_to_channel @@ -309,6 +311,7 @@ class StreamTest < ActionCable::TestCase ensure barrier.wait end + threads << thread1 # Thread 2: calls stream_from during unsubscribe_from_channel iteration thread2 = Thread.new do @@ -323,6 +326,7 @@ class StreamTest < ActionCable::TestCase ensure barrier.wait end + threads << thread2 thread1.join thread2.join @@ -331,7 +335,7 @@ class StreamTest < ActionCable::TestCase assert_nil exception_caught, "Concurrent unsubscribe_from_channel and stream_from should not raise RuntimeError: #{exception_caught}" end ensure - ENV.delete("UNSUBSCRIBE_SLEEP_TIME") + threads.each(&:kill) end private diff --git a/actioncable/test/stubs/test_adapter.rb b/actioncable/test/stubs/test_adapter.rb index 86bcbd54a2ac4..1ae5976fa0011 100644 --- a/actioncable/test/stubs/test_adapter.rb +++ b/actioncable/test/stubs/test_adapter.rb @@ -1,6 +1,13 @@ # frozen_string_literal: true class SuccessAdapter < ActionCable::SubscriptionAdapter::Base + attr_accessor :unsubscribe_latency + + def initialize(...) + super + @unsubscribe_latency = nil + end + def broadcast(channel, payload) end @@ -10,7 +17,7 @@ def subscribe(channel, callback, success_callback = nil) end def unsubscribe(channel, callback) - sleep ENV["UNSUBSCRIBE_SLEEP_TIME"].to_f if ENV["UNSUBSCRIBE_SLEEP_TIME"] + sleep @unsubscribe_latency if @unsubscribe_latency subscriber_map[channel].delete(callback) subscriber_map.delete(channel) if subscriber_map[channel].empty? @@unsubscribe_called = { channel: channel, callback: callback } From ad3275f675a147f06d84d416959e71275aa892e8 Mon Sep 17 00:00:00 2001 From: Wailan Tirajoh Date: Mon, 23 Jun 2025 00:51:39 +0700 Subject: [PATCH 0235/1075] Fix typo in `The Request and Response Objects` --- guides/source/action_controller_overview.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/guides/source/action_controller_overview.md b/guides/source/action_controller_overview.md index 0600460edb4a1..6e57fa17461f0 100644 --- a/guides/source/action_controller_overview.md +++ b/guides/source/action_controller_overview.md @@ -1182,7 +1182,7 @@ The Request and Response Objects Every controller has two methods, [`request`][] and [`response`][], which can be used to access the request and response objects associated with the current request cycle. The `request` method returns an instance of -[`ActionDispatch::Request`][]. The [`response`][] method returns an an instance +[`ActionDispatch::Request`][]. The [`response`][] method returns an instance of [`ActionDispatch::Response`][], an object representing what is going to be sent back to the client browser (e.g. from `render` or `redirect` in the controller action). From d5a3063ab9549a6da5b2af4f36b2d9cd1c98e516 Mon Sep 17 00:00:00 2001 From: Ermolaev Andrey Date: Mon, 6 Jan 2025 18:04:35 +0300 Subject: [PATCH 0236/1075] FileUpdateChecker and EventedFileUpdateChecker ignore changes in Gem.path now. Co-authored-by: zzak Co-authored-by: Jean Boussier Co-authored-by: Ermolaev Andrey --- activesupport/CHANGELOG.md | 4 ++ .../evented_file_update_checker.rb | 6 ++- .../lib/active_support/file_update_checker.rb | 7 ++- .../lib/active_support/i18n_railtie.rb | 3 +- .../test/file_update_checker_shared_tests.rb | 45 +++++++++++++++++++ 5 files changed, 60 insertions(+), 5 deletions(-) diff --git a/activesupport/CHANGELOG.md b/activesupport/CHANGELOG.md index 1397ebbf3a90f..cc9efbe79980b 100644 --- a/activesupport/CHANGELOG.md +++ b/activesupport/CHANGELOG.md @@ -1,3 +1,7 @@ +* FileUpdateChecker and EventedFileUpdateChecker ignore changes in Gem.path now. + + *Ermolaev Andrey*, *zzak* + * The new method `ActiveSupport::BacktraceCleaner#first_clean_frame` returns the first clean frame of the caller's backtrace, or `nil`. Useful when you want to report the application-level location where something happened. diff --git a/activesupport/lib/active_support/evented_file_update_checker.rb b/activesupport/lib/active_support/evented_file_update_checker.rb index 4bfc42d401d4b..f1aa1f4e99d84 100644 --- a/activesupport/lib/active_support/evented_file_update_checker.rb +++ b/activesupport/lib/active_support/evented_file_update_checker.rb @@ -73,9 +73,13 @@ class Core attr_reader :updated, :files def initialize(files, dirs) - @files = files.map { |file| Pathname(file).expand_path }.to_set + gem_paths = Gem.path + files = files.map { |f| Pathname(f).expand_path } + files.reject! { |f| f.to_s.start_with?(*gem_paths) } + @files = files.to_set @dirs = dirs.each_with_object({}) do |(dir, exts), hash| + next if dir.start_with?(*gem_paths) hash[Pathname(dir).expand_path] = Array(exts).map { |ext| ext.to_s.sub(/\A\.?/, ".") }.to_set end diff --git a/activesupport/lib/active_support/file_update_checker.rb b/activesupport/lib/active_support/file_update_checker.rb index 94edbfff32578..5fc9be8341dc3 100644 --- a/activesupport/lib/active_support/file_update_checker.rb +++ b/activesupport/lib/active_support/file_update_checker.rb @@ -46,8 +46,11 @@ def initialize(files, dirs = {}, &block) raise ArgumentError, "A block is required to initialize a FileUpdateChecker" end - @files = files.freeze - @globs = compile_glob(dirs) + gem_paths = Gem.path + @files = files.reject { |file| File.expand_path(file).start_with?(*gem_paths) }.freeze + + @globs = compile_glob(dirs)&.reject { |dir| dir.start_with?(*gem_paths) } + @block = block @watched = nil diff --git a/activesupport/lib/active_support/i18n_railtie.rb b/activesupport/lib/active_support/i18n_railtie.rb index de2ac0780f12b..1d6150fed7e43 100644 --- a/activesupport/lib/active_support/i18n_railtie.rb +++ b/activesupport/lib/active_support/i18n_railtie.rb @@ -66,8 +66,7 @@ def self.initialize_i18n(app) if app.config.reloading_enabled? directories = watched_dirs_with_extensions(reloadable_paths) - root_load_paths = I18n.load_path.select { |path| path.to_s.start_with?(Rails.root.to_s) } - reloader = app.config.file_watcher.new(root_load_paths, directories) do + reloader = app.config.file_watcher.new(I18n.load_path, directories) do I18n.load_path.delete_if { |path| path.to_s.start_with?(Rails.root.to_s) && !File.exist?(path) } I18n.load_path |= reloadable_paths.flat_map(&:existent) end diff --git a/activesupport/test/file_update_checker_shared_tests.rb b/activesupport/test/file_update_checker_shared_tests.rb index 505c81748a054..1ad53f840a092 100644 --- a/activesupport/test/file_update_checker_shared_tests.rb +++ b/activesupport/test/file_update_checker_shared_tests.rb @@ -33,6 +33,51 @@ def run(*args) assert_equal 0, i end + test "should exclude files in gem path" do + fake_gem_dir = File.join(tmpdir, "gemdir") + FileUtils.mkdir_p(fake_gem_dir) + gem_file = File.join(fake_gem_dir, "foo.rb") + local_file = tmpfile("bar.rb") + touched = [] + + Gem.stub(:path, [fake_gem_dir]) do + checker = new_checker([gem_file, local_file]) { touched << :called } + + touch(local_file) + assert checker.execute_if_updated + assert_equal [:called], touched + + touched.clear + touch(gem_file) + assert_not checker.execute_if_updated + assert_empty touched + end + end + + test "should exclude directories in gem path" do + local_dir = Dir.mktmpdir + fake_gem_dir = Dir.mktmpdir + local_file = File.join(local_dir, "foo.rb") + gem_file = File.join(fake_gem_dir, "bar.rb") + touched = [] + + Gem.stub(:path, [fake_gem_dir]) do + checker = new_checker([], { fake_gem_dir => [], local_dir => [] }) { touched << :called } + + touch(local_file) + assert checker.execute_if_updated + assert_equal [:called], touched + + touched.clear + touch(gem_file) + assert_not checker.execute_if_updated + assert_empty touched + end + ensure + FileUtils.remove_entry(local_dir) + FileUtils.remove_entry(fake_gem_dir) + end + test "should not execute the block if no files change" do i = 0 From a0ea187d777ac4423f7b0e4ba641acd6175f01e5 Mon Sep 17 00:00:00 2001 From: Xavier Noria Date: Mon, 23 Jun 2025 17:28:04 +0200 Subject: [PATCH 0237/1075] Implement ActiveSupport::BacktraceCleaner#first_clean_location --- activesupport/CHANGELOG.md | 10 +++- .../lib/active_support/backtrace_cleaner.rb | 32 +++++++++++ activesupport/test/backtrace_cleaner_test.rb | 54 ++++++++++++++++++- 3 files changed, 94 insertions(+), 2 deletions(-) diff --git a/activesupport/CHANGELOG.md b/activesupport/CHANGELOG.md index cc9efbe79980b..348a04fccacbb 100644 --- a/activesupport/CHANGELOG.md +++ b/activesupport/CHANGELOG.md @@ -1,10 +1,18 @@ +* The new method `ActiveSupport::BacktraceCleaner#first_clean_location` + returns the first clean location of the caller's call stack, or `nil`. + Locations are `Thread::Backtrace::Location` objects. Useful when you want to + report the application-level location where something happened as an object. + + *Xavier Noria* + * FileUpdateChecker and EventedFileUpdateChecker ignore changes in Gem.path now. *Ermolaev Andrey*, *zzak* * The new method `ActiveSupport::BacktraceCleaner#first_clean_frame` returns the first clean frame of the caller's backtrace, or `nil`. Useful when you - want to report the application-level location where something happened. + want to report the application-level frame where something happened as a + string. *Xavier Noria* diff --git a/activesupport/lib/active_support/backtrace_cleaner.rb b/activesupport/lib/active_support/backtrace_cleaner.rb index 755b3e4194b33..5c545eb071d02 100644 --- a/activesupport/lib/active_support/backtrace_cleaner.rb +++ b/activesupport/lib/active_support/backtrace_cleaner.rb @@ -77,6 +77,8 @@ def clean_frame(frame, kind = :silent) # Thread.each_caller_location does not accept a start in Ruby < 3.4. if Thread.method(:each_caller_location).arity == 0 # Returns the first clean frame of the caller's backtrace, or +nil+. + # + # Frames are strings. def first_clean_frame(kind = :silent) caller_location_skipped = false @@ -92,6 +94,8 @@ def first_clean_frame(kind = :silent) end else # Returns the first clean frame of the caller's backtrace, or +nil+. + # + # Frames are strings. def first_clean_frame(kind = :silent) Thread.each_caller_location(2) do |location| frame = clean_frame(location, kind) @@ -100,6 +104,34 @@ def first_clean_frame(kind = :silent) end end + # Thread.each_caller_location does not accept a start in Ruby < 3.4. + if Thread.method(:each_caller_location).arity == 0 + # Returns the first clean location of the caller's call stack, or +nil+. + # + # Locations are Thread::Backtrace::Location objects. + def first_clean_location(kind = :silent) + caller_location_skipped = false + + Thread.each_caller_location do |location| + unless caller_location_skipped + caller_location_skipped = true + next + end + + return location if clean_frame(location, kind) + end + end + else + # Returns the first clean location of the caller's call stack, or +nil+. + # + # Locations are Thread::Backtrace::Location objects. + def first_clean_location(kind = :silent) + Thread.each_caller_location(2) do |location| + return location if clean_frame(location, kind) + end + end + end + # Adds a filter from the block provided. Each line in the backtrace will be # mapped against this filter. # diff --git a/activesupport/test/backtrace_cleaner_test.rb b/activesupport/test/backtrace_cleaner_test.rb index 3bb85a9e848be..5e7a6ee8b920a 100644 --- a/activesupport/test/backtrace_cleaner_test.rb +++ b/activesupport/test/backtrace_cleaner_test.rb @@ -137,7 +137,7 @@ def setup end end -class BacktraceCleanerFirstCleanFrame < ActiveSupport::TestCase +class BacktraceCleanerFirstCleanFrameTest < ActiveSupport::TestCase def setup @bc = ActiveSupport::BacktraceCleaner.new end @@ -180,3 +180,55 @@ def invoke_first_clean_frame(kind = :silent) assert_nil invoke_first_clean_frame_defaults end end + +class BacktraceCleanerFirstCleanLocationTest < ActiveSupport::TestCase + def setup + @bc = ActiveSupport::BacktraceCleaner.new + end + + def invoke_first_clean_location_defaults + -> do + @bc.first_clean_location.tap { @line = __LINE__ + 1 } + end.call + end + + def invoke_first_clean_location(kind = :silent) + -> do + @bc.first_clean_location(kind).tap { @line = __LINE__ + 1 } + end.call + end + + test "returns the first clean location (defaults)" do + location = invoke_first_clean_location_defaults + + assert_equal __FILE__, location.path + assert_equal @line, location.lineno + end + + test "returns the first clean location (:silent)" do + location = invoke_first_clean_location(:silent) + + assert_equal __FILE__, location.path + assert_equal @line, location.lineno + end + + test "returns the first clean location (:noise)" do + @bc.add_silencer { true } + location = invoke_first_clean_location(:noise) + + assert_equal __FILE__, location.path + assert_equal @line, location.lineno + end + + test "returns the first clean location (:any)" do + location = invoke_first_clean_location(:any) # fallback of the case statement + + assert_equal __FILE__, location.path + assert_equal @line, location.lineno + end + + test "returns nil if there is no clean location" do + @bc.add_silencer { true } + assert_nil invoke_first_clean_location_defaults + end +end From 7b680f8ed3eb038a323890a44bbc5b4276394f28 Mon Sep 17 00:00:00 2001 From: Fabrice Renard Date: Mon, 23 Jun 2025 05:53:28 +0000 Subject: [PATCH 0238/1075] Fix #55215 test failure --- railties/lib/rails/application_controller.rb | 2 ++ railties/lib/rails/health_controller.rb | 2 ++ 2 files changed, 4 insertions(+) diff --git a/railties/lib/rails/application_controller.rb b/railties/lib/rails/application_controller.rb index 0ea35b2ad9c10..61516c5b53d17 100644 --- a/railties/lib/rails/application_controller.rb +++ b/railties/lib/rails/application_controller.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "action_controller" + class Rails::ApplicationController < ActionController::Base # :nodoc: prepend_view_path File.expand_path("templates", __dir__) layout "application" diff --git a/railties/lib/rails/health_controller.rb b/railties/lib/rails/health_controller.rb index 57337610955b2..b93b1eeef0837 100644 --- a/railties/lib/rails/health_controller.rb +++ b/railties/lib/rails/health_controller.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "action_controller" + module Rails # Built-in Health Check Endpoint # From 59c2e322baf3638c01f44ab5cfa798311c09cb63 Mon Sep 17 00:00:00 2001 From: zzak Date: Mon, 23 Jun 2025 14:07:12 +0900 Subject: [PATCH 0239/1075] Add note to Migrations guide for `migrations_paths` option Co-authored-by: Alex Kitchens --- guides/source/active_record_migrations.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/guides/source/active_record_migrations.md b/guides/source/active_record_migrations.md index 7c7445d265b83..bdcb50d1126f8 100644 --- a/guides/source/active_record_migrations.md +++ b/guides/source/active_record_migrations.md @@ -112,6 +112,9 @@ another application or generating a file yourself, be aware of its position in the order. You can read more about how the timestamps are used in the [Rails Migration Version Control section](#rails-migration-version-control). +NOTE: You can override the directory that migrations are stored in by setting the +`migrations_paths` option in your `config/database.yml`. + When generating a migration, Active Record automatically prepends the current timestamp to the file name of the migration. For example, running the command below will create an empty migration file whereby the filename is made up of a From dc2384d5dd3a1798ff0e8485b32665a074211bb4 Mon Sep 17 00:00:00 2001 From: Yasuo Honda Date: Wed, 25 Jun 2025 09:39:31 +0900 Subject: [PATCH 0240/1075] Address TestERBTemplate test failure with Ruby 3.5.0dev This commit addresses the `TestERBTemplate#test_argument_error_in_the_template_is_not_hijacked_by_strict_locals_checking` failure with Ruby 3.5.0dev including https://github.com/ruby/ruby/commit/3546cedde3f6f46f00fd67b73081cbfbb83144de - Without this commit: ``` $ cd actionview $ ruby -v ; rm ../Gemfile.lock ; bundle ; bin/test test/template/template_test.rb:243 ruby 3.5.0dev (2025-06-24T02:39:58Z master 3546cedde3) +PRISM [x86_64-linux] ... snip ... F Failure: TestERBTemplate#test_argument_error_in_the_template_is_not_hijacked_by_strict_locals_checking [test/template/template_test.rb:249]: Expected /in ['`]hello'/ to match "/home/yahonda/src/github.com/rails/rails/actionview/test/template/template_test.rb:27:in 'TestERBTemplate::Context#hello'". bin/test test/template/template_test.rb:243 Finished in 0.006231s, 160.4981 runs/s, 481.4942 assertions/s. 1 runs, 3 assertions, 1 failures, 0 errors, 0 skips $ ``` Here are `pp error.backtrace.first` differences between Ruby versions: ```ruby $ git diff diff --git a/actionview/test/template/template_test.rb b/actionview/test/template/template_test.rb index e8fa3c8e81..688b5b1f0f 100644 --- a/actionview/test/template/template_test.rb +++ b/actionview/test/template/template_test.rb @@ -245,7 +245,7 @@ def test_argument_error_in_the_template_is_not_hijacked_by_strict_locals_checkin @template = new_template("<%# locals: () -%>\n<%= hello(:invalid_argument) %>") render end - + pp error.backtrace.first assert_match(/in ['`].*hello'/, error.backtrace.first) assert_instance_of ArgumentError, error.cause end $ ``` - Ruby 3.3.8 `ruby 3.3.8 (2025-04-09 revision b200bad6cd) [x86_64-linux]` "/home/yahonda/src/github.com/rails/rails/actionview/test/template/template_test.rb:27:in `hello'" - Ruby 3.4.4 `ruby 3.4.4 (2025-05-14 revision a38531fd3f) +PRISM [x86_64-linux]` "/home/yahonda/src/github.com/rails/rails/actionview/test/template/template_test.rb:27:in 'hello'" - Ruby master `ruby 3.5.0dev (2025-06-24T02:39:58Z master 3546cedde3) +PRISM [x86_64-linux]` "/home/yahonda/src/github.com/rails/rails/actionview/test/template/template_test.rb:27:in 'TestERBTemplate::Context#hello'" Refer to: https://github.com/ruby/ruby/pull/13679 https://bugs.ruby-lang.org/issues/20968#change-113811 --- actionview/test/template/template_test.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/actionview/test/template/template_test.rb b/actionview/test/template/template_test.rb index 083294449740e..e8fa3c8e8157f 100644 --- a/actionview/test/template/template_test.rb +++ b/actionview/test/template/template_test.rb @@ -246,7 +246,7 @@ def test_argument_error_in_the_template_is_not_hijacked_by_strict_locals_checkin render end - assert_match(/in ['`]hello'/, error.backtrace.first) + assert_match(/in ['`].*hello'/, error.backtrace.first) assert_instance_of ArgumentError, error.cause end From fa2b2b1b310676955215115e987a4a974ef796e7 Mon Sep 17 00:00:00 2001 From: Bengt-Ove Hollaender Date: Wed, 7 May 2025 16:30:29 +0100 Subject: [PATCH 0241/1075] Add locale options to PostgreSQL adapter DB create This adds `locale_provider` and `locale` options to the PostgreSQL adapter's DB creation statement. --- activerecord/CHANGELOG.md | 4 ++++ .../postgresql/schema_statements.rb | 11 ++++++++--- .../cases/adapters/postgresql/active_schema_test.rb | 4 ++++ 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/activerecord/CHANGELOG.md b/activerecord/CHANGELOG.md index 3827ab70f6e2f..d3c96f03d4482 100644 --- a/activerecord/CHANGELOG.md +++ b/activerecord/CHANGELOG.md @@ -1,3 +1,7 @@ +* PostgreSQL adapter create DB now supports `locale_provider` and `locale`. + + *Bengt-Ove Hollaender* + * Use ntuples to populate row_count instead of count for Postgres *Jonathan Calvert* 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 a6903797ee30e..3d2bbc40994ca 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/schema_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/schema_statements.rb @@ -12,9 +12,10 @@ def recreate_database(name, options = {}) # :nodoc: end # Create a new PostgreSQL database. Options include :owner, :template, - # :encoding (defaults to utf8), :collation, :ctype, - # :tablespace, and :connection_limit (note that MySQL uses - # :charset while PostgreSQL uses :encoding). + # :encoding (defaults to utf8), :locale_provider, :locale, + # :collation, :ctype, :tablespace, and + # :connection_limit (note that MySQL uses :charset while PostgreSQL + # uses :encoding). # # Example: # create_database config[:database], config @@ -30,6 +31,10 @@ def create_database(name, options = {}) " TEMPLATE = \"#{value}\"" when :encoding " ENCODING = '#{value}'" + when :locale_provider + " LOCALE_PROVIDER = '#{value}'" + when :locale + " LOCALE = '#{value}'" when :collation " LC_COLLATE = '#{value}'" when :ctype diff --git a/activerecord/test/cases/adapters/postgresql/active_schema_test.rb b/activerecord/test/cases/adapters/postgresql/active_schema_test.rb index c9f6a5f7df336..fc99b0ac25b57 100644 --- a/activerecord/test/cases/adapters/postgresql/active_schema_test.rb +++ b/activerecord/test/cases/adapters/postgresql/active_schema_test.rb @@ -27,6 +27,10 @@ def test_create_database_with_collation_and_ctype assert_equal %(CREATE DATABASE "aimonetti" ENCODING = 'UTF8' LC_COLLATE = 'ja_JP.UTF8' LC_CTYPE = 'ja_JP.UTF8'), create_database(:aimonetti, encoding: :"UTF8", collation: :"ja_JP.UTF8", ctype: :"ja_JP.UTF8") end + def test_create_database_with_locale_provider_and_locale + assert_equal %(CREATE DATABASE "aimonetti" ENCODING = 'utf8' LOCALE_PROVIDER = 'builtin' LOCALE = 'C.UTF-8'), create_database(:aimonetti, locale_provider: :builtin, locale: "C.UTF-8") + end + def test_add_index expected = %(CREATE UNIQUE INDEX "index_people_on_last_name" ON "people" ("last_name") WHERE state = 'active') assert_equal expected, add_index(:people, :last_name, unique: true, where: "state = 'active'") From 1b4484c43f32a42824d3de32e22029207f76bc42 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Wed, 25 Jun 2025 19:06:26 +0100 Subject: [PATCH 0242/1075] Active Job Continuation isolated steps (#55212) Add an isolated option to steps. Defaults to false. Isolated steps are always run in their own job execution. We do this by interrupting the job before running a step if both: 1. It is not the first step we've run in this execution 2. Either this step or the previous one was marked as isolated. The first to be run in an execution is always run inline so we don't end up in an infinite loop of interruptions. This allows you to execute a long running step separately which is useful to ensure that progress is saved before it runs. --- activejob/lib/active_job/continuable.rb | 16 ++--- activejob/lib/active_job/continuation.rb | 38 ++++++++++-- activejob/lib/active_job/log_subscriber.rb | 2 +- activejob/test/cases/continuation_test.rb | 71 +++++++++++++++++++++- 4 files changed, 112 insertions(+), 15 deletions(-) diff --git a/activejob/lib/active_job/continuable.rb b/activejob/lib/active_job/continuable.rb index d1d11cae71baa..2d25b751506ec 100644 --- a/activejob/lib/active_job/continuable.rb +++ b/activejob/lib/active_job/continuable.rb @@ -33,7 +33,7 @@ def initialize(...) attr_accessor :continuation # :nodoc: # Start a new continuation step - def step(step_name, start: nil, &block) + def step(step_name, start: nil, isolated: false, &block) unless block_given? step_method = method(step_name) @@ -46,7 +46,7 @@ def step(step_name, start: nil, &block) block = step_method.arity == 0 ? -> (_) { step_method.call } : step_method end checkpoint! if continuation.advanced? - continuation.step(step_name, start: start, &block) + continuation.step(step_name, start: start, isolated: isolated, &block) end def serialize # :nodoc: @@ -60,7 +60,12 @@ def deserialize(job_data) # :nodoc: end def checkpoint! # :nodoc: - interrupt! if queue_adapter.stopping? + interrupt!(reason: :stopping) if queue_adapter.stopping? + end + + def interrupt!(reason:) # :nodoc: + instrument :interrupt, reason: reason, **continuation.instrumentation + raise Continuation::Interrupt, "Interrupted #{continuation.description} (#{reason})" end private @@ -91,11 +96,6 @@ def resume_job(exception) # :nodoc: raise Continuation::ResumeLimitError, "Job was resumed a maximum of #{max_resumptions} times" end end - - def interrupt! # :nodoc: - instrument :interrupt, **continuation.instrumentation - raise Continuation::Interrupt, "Interrupted #{continuation.description}" - end end ActiveSupport.run_load_hooks(:active_job_continuable, Continuable) diff --git a/activejob/lib/active_job/continuation.rb b/activejob/lib/active_job/continuation.rb index 6b2d5ba095cf3..d322fb7436813 100644 --- a/activejob/lib/active_job/continuation.rb +++ b/activejob/lib/active_job/continuation.rb @@ -150,6 +150,21 @@ module ActiveJob # - a list of the completed steps # - the current step and its cursor value (if one is in progress) # + # === Isolated Steps + # + # Steps run sequentially in a single job execution, unless the job is interrupted. + # + # You can specify that a step should always run in its own execution by passing the +isolated: true+ option. + # + # This is useful for long-running steps where it may not be possible to checkpoint within + # the job grace period - it ensures that progress is serialized back into the job data before + # the step starts. + # + # step :quick_step1 + # step :slow_step, isolated: true + # step :quick_step2 + # step :quick_step3 + # # === Errors # # If a job raises an error and is not retried via Active Job, it will be passed back to the underlying @@ -204,23 +219,24 @@ def initialize(job, serialized_progress) # :nodoc: @encountered = [] @advanced = false @running_step = false + @isolating = false end - def step(name, start:, &block) # :nodoc: + def step(name, **options, &block) # :nodoc: validate_step!(name) encountered << name if completed?(name) skip_step(name) else - run_step(name, start: start, &block) + run_step(name, **options, &block) end end def to_h # :nodoc: { "completed" => completed.map(&:to_s), - "current" => current&.to_a + "current" => current&.to_a, }.compact end @@ -255,6 +271,10 @@ def running_step? @running_step end + def isolating? + @isolating + end + def completed?(name) completed.include?(name) end @@ -267,7 +287,17 @@ def skip_step(name) instrument :step_skipped, step: name end - def run_step(name, start:, &block) + def run_step(name, start:, isolated:, &block) + @isolating ||= isolated + + if isolating? && advanced? + job.interrupt!(reason: :isolating) + else + run_step_inline(name, start: start, &block) + end + end + + def run_step_inline(name, start:, **options, &block) @running_step = true @current ||= new_step(name, start, resumed: false) diff --git a/activejob/lib/active_job/log_subscriber.rb b/activejob/lib/active_job/log_subscriber.rb index eda0ae60ccccd..6587e01d65bde 100644 --- a/activejob/lib/active_job/log_subscriber.rb +++ b/activejob/lib/active_job/log_subscriber.rb @@ -141,7 +141,7 @@ def discard(event) def interrupt(event) job = event.payload[:job] info do - "Interrupted #{job.class} (Job ID: #{job.job_id}) #{event.payload[:description]}" + "Interrupted #{job.class} (Job ID: #{job.job_id}) #{event.payload[:description]} (#{event.payload[:reason]})" end end subscribe_log_level :interrupt, :info diff --git a/activejob/test/cases/continuation_test.rb b/activejob/test/cases/continuation_test.rb index b8e11b553e073..7f7900a37c262 100644 --- a/activejob/test/cases/continuation_test.rb +++ b/activejob/test/cases/continuation_test.rb @@ -258,7 +258,7 @@ def perform assert_no_match "Resuming", @logger.messages assert_match(/Step 'step_one' started/, @logger.messages) assert_match(/Step 'step_one' completed/, @logger.messages) - assert_match(/Interrupted ActiveJob::TestContinuation::LinearJob \(Job ID: [0-9a-f-]{36}\) after 'step_one'/, @logger.messages) + assert_match(/Interrupted ActiveJob::TestContinuation::LinearJob \(Job ID: [0-9a-f-]{36}\) after 'step_one' \(stopping\)/, @logger.messages) end perform_enqueued_jobs @@ -278,7 +278,7 @@ def perform assert_no_match "Resuming", @logger.messages assert_match(/Step 'rename' started/, @logger.messages) assert_match(/Step 'rename' interrupted at cursor '433'/, @logger.messages) - assert_match(/Interrupted ActiveJob::TestContinuation::IteratingJob \(Job ID: [0-9a-f-]{36}\) at 'rename', cursor '433'/, @logger.messages) + assert_match(/Interrupted ActiveJob::TestContinuation::IteratingJob \(Job ID: [0-9a-f-]{36}\) at 'rename', cursor '433' \(stopping\)/, @logger.messages) end perform_enqueued_jobs @@ -651,6 +651,73 @@ def perform(iterations) end end + class IsolatedStepsJob < ContinuableJob + cattr_accessor :items, default: [] + + def perform(*isolated) + step :step_one, isolated: isolated.include?(:step_one) do |step| + items << "step_one" + end + step :step_two, isolated: isolated.include?(:step_two) do |step| + items << "step_two" + end + step :step_three, isolated: isolated.include?(:step_three) do |step| + items << "step_three" + end + step :step_four, isolated: isolated.include?(:step_four) do |step| + items << "step_four" + end + end + end + + test "runs isolated step separately" do + IsolatedStepsJob.items = [] + IsolatedStepsJob.perform_later(:step_three) + + assert_enqueued_jobs 1, only: IsolatedStepsJob do + perform_enqueued_jobs + end + + assert_equal [ "step_one", "step_two" ], IsolatedStepsJob.items + + assert_enqueued_jobs 1 do + perform_enqueued_jobs + end + + assert_equal [ "step_one", "step_two", "step_three" ], IsolatedStepsJob.items + + assert_enqueued_jobs 0 do + perform_enqueued_jobs + end + + assert_equal [ "step_one", "step_two", "step_three", "step_four" ], IsolatedStepsJob.items + assert_match(/Interrupted ActiveJob::TestContinuation::IsolatedStepsJob \(Job ID: [0-9a-f-]{36}\) after 'step_two' \(isolating\)/, @logger.messages) + assert_match(/Interrupted ActiveJob::TestContinuation::IsolatedStepsJob \(Job ID: [0-9a-f-]{36}\) after 'step_three' \(isolating\)/, @logger.messages) + end + + test "runs initial and final isolated steps separately" do + IsolatedStepsJob.items = [] + IsolatedStepsJob.perform_later(:step_one, :step_four) + + assert_enqueued_jobs 1, only: IsolatedStepsJob do + perform_enqueued_jobs + end + + assert_equal [ "step_one" ], IsolatedStepsJob.items + + assert_enqueued_jobs 1 do + perform_enqueued_jobs + end + + assert_equal [ "step_one", "step_two", "step_three" ], IsolatedStepsJob.items + + assert_enqueued_jobs 0 do + perform_enqueued_jobs + end + + assert_equal [ "step_one", "step_two", "step_three", "step_four" ], IsolatedStepsJob.items + end + private def capture_info_stdout(&block) ActiveJob::Base.logger.with(level: :info) do From 1c80e7f8c4b4cd1582b81d5ecffc5e76b6ba49c4 Mon Sep 17 00:00:00 2001 From: Earlopain <14981592+Earlopain@users.noreply.github.com> Date: Thu, 26 Jun 2025 09:15:21 +0200 Subject: [PATCH 0243/1075] Fix link in guides for `assert_difference` method [ci skip] --- guides/source/testing.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/guides/source/testing.md b/guides/source/testing.md index debd36a972050..7aa9e3c7df47e 100644 --- a/guides/source/testing.md +++ b/guides/source/testing.md @@ -440,7 +440,7 @@ Rails adds some custom assertions of its own to the `minitest` framework: | [`assert_queries_match(pattern, count: nil, include_schema: false, &block)`][] | Asserts that `&block` generates SQL queries that match the pattern.| | [`assert_no_queries_match(pattern, &block)`][] | Asserts that `&block` generates no SQL queries that match the pattern.| -[`assert_difference(expressions, difference = 1, message = nil) {...}`]: https://api.rubyonrails.org/classes/ActiveSupport/Testing/Assertions.html#method-i-assert_difference) +[`assert_difference(expressions, difference = 1, message = nil) {...}`]: https://api.rubyonrails.org/classes/ActiveSupport/Testing/Assertions.html#method-i-assert_difference [`assert_no_difference(expressions, message = nil, &block)`]: https://api.rubyonrails.org/classes/ActiveSupport/Testing/Assertions.html#method-i-assert_no_difference [`assert_changes(expressions, message = nil, from:, to:, &block)`]: https://api.rubyonrails.org/classes/ActiveSupport/Testing/Assertions.html#method-i-assert_changes [`assert_no_changes(expressions, message = nil, &block)`]: https://api.rubyonrails.org/classes/ActiveSupport/Testing/Assertions.html#method-i-assert_no_changes From d43dc9af18766f91eb450e1c6b8c8c5e64f925e6 Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Thu, 26 Jun 2025 13:17:41 +0200 Subject: [PATCH 0244/1075] Allow for nested ExecutionContext in test `ActiveSupport::CurrentAttribute` lifetime in test always have been a bit wonky. This was addressed in the past by https://github.com/rails/rails/pull/43734 and then https://github.com/rails/rails/pull/43550 but a problem remained that various states would leak from the test case into the controller or job being tested, and back. Ideally when executing a controller or job from a test case, we want to "save and restore" current attributes and the execution context. This is what this commit does, by implementing `push` and `pop` logic for `ActiveSupport::ExecutionContext`. For extra safety, since such use case isn't supposed to ever happen in production, this behavior is test only and in production we keep dropping all the state. --- activestorage/test/test_helper.rb | 6 +- activesupport/CHANGELOG.md | 18 +++++ .../lib/active_support/current_attributes.rb | 9 ++- .../lib/active_support/execution_context.rb | 71 +++++++++++++++++-- activesupport/lib/active_support/railtie.rb | 22 +++--- activesupport/test/current_attributes_test.rb | 49 ++++++++++++- 6 files changed, 150 insertions(+), 25 deletions(-) diff --git a/activestorage/test/test_helper.rb b/activestorage/test/test_helper.rb index ba45d38d37407..12dae0da20609 100644 --- a/activestorage/test/test_helper.rb +++ b/activestorage/test/test_helper.rb @@ -12,6 +12,7 @@ require "active_support/testing/autorun" require "image_processing/mini_magick" +require "active_support/current_attributes/test_helper" require "active_record/testing/query_assertions" require "active_job" @@ -25,6 +26,7 @@ class ActiveSupport::TestCase self.file_fixture_path = ActiveStorage::FixtureSet.file_fixture_path + include ActiveSupport::CurrentAttributes::TestHelper include ActiveRecord::TestFixtures include ActiveRecord::Assertions::QueryAssertions @@ -34,10 +36,6 @@ class ActiveSupport::TestCase ActiveStorage::Current.url_options = { protocol: "https://", host: "example.com", port: nil } end - teardown do - ActiveStorage::Current.reset - end - private def create_blob(key: nil, data: "Hello world!", filename: "hello.txt", content_type: "text/plain", identify: true, service_name: nil, record: nil) ActiveStorage::Blob.create_and_upload! key: key, io: StringIO.new(data), filename: filename, content_type: content_type, identify: identify, service_name: service_name, record: record diff --git a/activesupport/CHANGELOG.md b/activesupport/CHANGELOG.md index 348a04fccacbb..68036ede40b76 100644 --- a/activesupport/CHANGELOG.md +++ b/activesupport/CHANGELOG.md @@ -1,3 +1,21 @@ +* Improve `CurrentAttribute` and `ExecutionContext` state managment in test cases. + + Previously these two global state would be entirely cleared out whenever calling + into code that is wrapped by the Rails executor, typically Action Controller or + Active Job helpers: + + ```ruby + test "#index works" do + CurrentUser.id = 42 + get :index + CurrentUser.id == nil + end + ``` + + Now re-entering the executor properly save and restore that state. + + *Jean Boussier* + * The new method `ActiveSupport::BacktraceCleaner#first_clean_location` returns the first clean location of the caller's call stack, or `nil`. Locations are `Thread::Backtrace::Location` objects. Useful when you want to diff --git a/activesupport/lib/active_support/current_attributes.rb b/activesupport/lib/active_support/current_attributes.rb index 76e2137386bc0..402e289033662 100644 --- a/activesupport/lib/active_support/current_attributes.rb +++ b/activesupport/lib/active_support/current_attributes.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "active_support/callbacks" +require "active_support/execution_context" require "active_support/core_ext/object/with" require "active_support/core_ext/enumerable" require "active_support/core_ext/module/delegation" @@ -154,8 +155,10 @@ def resets(*methods, &block) delegate :set, :reset, to: :instance def clear_all # :nodoc: - current_instances.each_value(&:reset) - current_instances.clear + if instances = current_instances + instances.values.each(&:reset) + instances.clear + end end private @@ -164,7 +167,7 @@ def generated_attribute_methods end def current_instances - IsolatedExecutionState[:current_attributes_instances] ||= {} + ExecutionContext.current_attributes_instances end def current_instances_key diff --git a/activesupport/lib/active_support/execution_context.rb b/activesupport/lib/active_support/execution_context.rb index 1c95188bae5cd..e720b7b397fab 100644 --- a/activesupport/lib/active_support/execution_context.rb +++ b/activesupport/lib/active_support/execution_context.rb @@ -2,8 +2,41 @@ module ActiveSupport module ExecutionContext # :nodoc: + class Record + attr_reader :store, :current_attributes_instances + + def initialize + @store = {} + @current_attributes_instances = {} + @stack = [] + end + + def push + @stack << @store << @current_attributes_instances + @store = {} + @current_attributes_instances = {} + self + end + + def pop + @current_attributes_instances = @stack.pop + @store = @stack.pop + self + end + end + @after_change_callbacks = [] + + # Execution context nesting should only legitimately happen during test + # because the test case itself is wrapped in an executor, and it might call + # into a controller or job which should be executed with their own fresh context. + # However in production this should never happen, and for extra safety we make sure to + # fully clear the state at the end of the request or job cycle. + @nestable = false + class << self + attr_accessor :nestable + def after_change(&block) @after_change_callbacks << block end @@ -14,9 +47,11 @@ def set(**options) options.symbolize_keys! keys = options.keys - store = self.store + store = record.store - previous_context = keys.zip(store.values_at(*keys)).to_h + previous_context = if block_given? + keys.zip(store.values_at(*keys)).to_h + end store.merge!(options) @after_change_callbacks.each(&:call) @@ -32,21 +67,43 @@ def set(**options) end def []=(key, value) - store[key.to_sym] = value + record.store[key.to_sym] = value @after_change_callbacks.each(&:call) end def to_h - store.dup + record.store.dup + end + + def push + if @nestable + record.push + else + clear + end + self + end + + def pop + if @nestable + record.pop + else + clear + end + self end def clear - store.clear + IsolatedExecutionState[:active_support_execution_context] = nil + end + + def current_attributes_instances + record.current_attributes_instances end private - def store - IsolatedExecutionState[:active_support_execution_context] ||= {} + def record + IsolatedExecutionState[:active_support_execution_context] ||= Record.new end end end diff --git a/activesupport/lib/active_support/railtie.rb b/activesupport/lib/active_support/railtie.rb index 33e4cd0be62f2..5ab73c8d08ae3 100644 --- a/activesupport/lib/active_support/railtie.rb +++ b/activesupport/lib/active_support/railtie.rb @@ -39,18 +39,24 @@ class Railtie < Rails::Railtie # :nodoc: end initializer "active_support.reset_execution_context" do |app| - app.reloader.before_class_unload { ActiveSupport::ExecutionContext.clear } - app.executor.to_run { ActiveSupport::ExecutionContext.clear } - app.executor.to_complete { ActiveSupport::ExecutionContext.clear } - end + app.reloader.before_class_unload do + ActiveSupport::CurrentAttributes.clear_all + ActiveSupport::ExecutionContext.clear + end + + app.executor.to_run do + ActiveSupport::ExecutionContext.push + end - initializer "active_support.clear_all_current_attributes_instances" do |app| - app.reloader.before_class_unload { ActiveSupport::CurrentAttributes.clear_all } - app.executor.to_run { ActiveSupport::CurrentAttributes.clear_all } - app.executor.to_complete { ActiveSupport::CurrentAttributes.clear_all } + app.executor.to_complete do + ActiveSupport::CurrentAttributes.clear_all + ActiveSupport::ExecutionContext.pop + end ActiveSupport.on_load(:active_support_test_case) do if app.config.active_support.executor_around_test_case + ActiveSupport::ExecutionContext.nestable = true + require "active_support/executor/test_helper" include ActiveSupport::Executor::TestHelper else diff --git a/activesupport/test/current_attributes_test.rb b/activesupport/test/current_attributes_test.rb index 23bc26cc16795..fdcfbe0cb7e24 100644 --- a/activesupport/test/current_attributes_test.rb +++ b/activesupport/test/current_attributes_test.rb @@ -66,9 +66,6 @@ class Session < ActiveSupport::CurrentAttributes attribute :current, :previous end - # Eagerly set-up `instance`s by reference. - [ Current.instance, Session.instance ] - # Use library specific minitest hook to catch Time.zone before reset is called via TestHelper def before_setup @original_time_zone = Time.zone @@ -307,4 +304,50 @@ def self.name assert_equal [], current.method(:attr).parameters assert_equal [[:req, :value]], current.method(:attr=).parameters end + + + test "set and restore attributes when re-entering the executor" do + ActiveSupport::ExecutionContext.with(nestable: true) do + # simulate executor hooks from active_support/railtie.rb + executor = Class.new(ActiveSupport::Executor) + executor.to_run do + ActiveSupport::ExecutionContext.push + end + + executor.to_complete do + ActiveSupport::CurrentAttributes.clear_all + ActiveSupport::ExecutionContext.pop + end + + Current.world = "world/1" + Current.account = "account/1" + + assert_equal "world/1", Current.world + assert_equal "account/1", Current.account + + Current.set(world: "world/2", account: "account/2") do + assert_equal "world/2", Current.world + assert_equal "account/2", Current.account + + executor.wrap do + assert_nil Current.world + assert_nil Current.account + + Current.world = "world/3" + Current.account = "account/3" + + assert_equal "world/3", Current.world + assert_equal "account/3", Current.account + + ActiveSupport::CurrentAttributes.clear_all + + assert_nil Current.world + assert_nil Current.account + end + end + + assert_equal "world/1", Current.world + assert_equal "account/1", Current.account + end + end end From 9a14fb110d937a99d126e003cb60b8624f877846 Mon Sep 17 00:00:00 2001 From: Xavier Noria Date: Fri, 27 Jun 2025 12:58:45 +0200 Subject: [PATCH 0245/1075] Improve the docs of AS::BacktraceCleaner#first_clean_location --- activesupport/lib/active_support/backtrace_cleaner.rb | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/activesupport/lib/active_support/backtrace_cleaner.rb b/activesupport/lib/active_support/backtrace_cleaner.rb index 5c545eb071d02..de10c3c7af910 100644 --- a/activesupport/lib/active_support/backtrace_cleaner.rb +++ b/activesupport/lib/active_support/backtrace_cleaner.rb @@ -108,7 +108,9 @@ def first_clean_frame(kind = :silent) if Thread.method(:each_caller_location).arity == 0 # Returns the first clean location of the caller's call stack, or +nil+. # - # Locations are Thread::Backtrace::Location objects. + # Locations are Thread::Backtrace::Location objects. Since they are + # immutable, their +path+ attributes are the original ones, but filters + # are applied internally so silencers can still rely on them. def first_clean_location(kind = :silent) caller_location_skipped = false @@ -124,7 +126,9 @@ def first_clean_location(kind = :silent) else # Returns the first clean location of the caller's call stack, or +nil+. # - # Locations are Thread::Backtrace::Location objects. + # Locations are Thread::Backtrace::Location objects. Since they are + # immutable, their +path+ attributes are the original ones, but filters + # are applied internally so silencers can still rely on them. def first_clean_location(kind = :silent) Thread.each_caller_location(2) do |location| return location if clean_frame(location, kind) From 90068225c07136f1fee56cf0a1ebb7829d98fbd2 Mon Sep 17 00:00:00 2001 From: Xavier Noria Date: Fri, 27 Jun 2025 13:01:28 +0200 Subject: [PATCH 0246/1075] Group methods under same Ruby constraints --- .../lib/active_support/backtrace_cleaner.rb | 23 ++++++++----------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/activesupport/lib/active_support/backtrace_cleaner.rb b/activesupport/lib/active_support/backtrace_cleaner.rb index de10c3c7af910..e2415fa821b2c 100644 --- a/activesupport/lib/active_support/backtrace_cleaner.rb +++ b/activesupport/lib/active_support/backtrace_cleaner.rb @@ -92,20 +92,7 @@ def first_clean_frame(kind = :silent) return frame if frame end end - else - # Returns the first clean frame of the caller's backtrace, or +nil+. - # - # Frames are strings. - def first_clean_frame(kind = :silent) - Thread.each_caller_location(2) do |location| - frame = clean_frame(location, kind) - return frame if frame - end - end - end - # Thread.each_caller_location does not accept a start in Ruby < 3.4. - if Thread.method(:each_caller_location).arity == 0 # Returns the first clean location of the caller's call stack, or +nil+. # # Locations are Thread::Backtrace::Location objects. Since they are @@ -124,6 +111,16 @@ def first_clean_location(kind = :silent) end end else + # Returns the first clean frame of the caller's backtrace, or +nil+. + # + # Frames are strings. + def first_clean_frame(kind = :silent) + Thread.each_caller_location(2) do |location| + frame = clean_frame(location, kind) + return frame if frame + end + end + # Returns the first clean location of the caller's call stack, or +nil+. # # Locations are Thread::Backtrace::Location objects. Since they are From c7074bb81d74aed15d9f971f12800421fc3fce9c Mon Sep 17 00:00:00 2001 From: Igor Alexandrov Date: Fri, 27 Jun 2025 15:27:59 +0400 Subject: [PATCH 0247/1075] Moved LD_PRELOAD variable initialization to the Dockerfile from the entrypoint --- .../lib/rails/generators/rails/app/templates/Dockerfile.tt | 6 ++++-- .../generators/rails/app/templates/docker-entrypoint.tt | 6 ------ 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/railties/lib/rails/generators/rails/app/templates/Dockerfile.tt b/railties/lib/rails/generators/rails/app/templates/Dockerfile.tt index 0c39522dae0dd..b807b820393c1 100644 --- a/railties/lib/rails/generators/rails/app/templates/Dockerfile.tt +++ b/railties/lib/rails/generators/rails/app/templates/Dockerfile.tt @@ -17,13 +17,15 @@ WORKDIR /rails # Install base packages RUN apt-get update -qq && \ apt-get install --no-install-recommends -y <%= dockerfile_base_packages.join(" ") %> && \ + ln -s /usr/lib/$(uname -m)-linux-gnu/libjemalloc.so.2 /usr/local/lib/libjemalloc.so && \ rm -rf /var/lib/apt/lists /var/cache/apt/archives -# Set production environment +# Set production environment variables and enable jemalloc for reduced memory usage and latency. ENV RAILS_ENV="production" \ BUNDLE_DEPLOYMENT="1" \ BUNDLE_PATH="/usr/local/bundle" \ - BUNDLE_WITHOUT="development" + BUNDLE_WITHOUT="development" \ + LD_PRELOAD=/usr/local/lib/libjemalloc.so # Throw-away build stage to reduce size of final image FROM base AS build diff --git a/railties/lib/rails/generators/rails/app/templates/docker-entrypoint.tt b/railties/lib/rails/generators/rails/app/templates/docker-entrypoint.tt index 3234606742a42..7594fe9cd3e4a 100644 --- a/railties/lib/rails/generators/rails/app/templates/docker-entrypoint.tt +++ b/railties/lib/rails/generators/rails/app/templates/docker-entrypoint.tt @@ -1,11 +1,5 @@ #!/bin/bash -e -# Enable jemalloc for reduced memory usage and latency. -if [ -z "${LD_PRELOAD+x}" ]; then - LD_PRELOAD=$(find /usr/lib -name libjemalloc.so.2 -print -quit) - export LD_PRELOAD -fi - <% unless skip_active_record? -%> # If running the rails server then create or migrate existing database if [ "${@: -2:1}" == "./bin/rails" ] && [ "${@: -1:1}" == "server" ]; then From 5c456627237559596334c2c7d1d7585ba5fff147 Mon Sep 17 00:00:00 2001 From: Xavier Noria Date: Fri, 27 Jun 2025 17:00:37 +0200 Subject: [PATCH 0248/1075] Implement ActiveSupport::BacktraceCleaner#clean_locations --- activesupport/CHANGELOG.md | 14 ++++++ .../lib/active_support/backtrace_cleaner.rb | 12 +++++ activesupport/test/backtrace_cleaner_test.rb | 45 +++++++++++++++++++ 3 files changed, 71 insertions(+) diff --git a/activesupport/CHANGELOG.md b/activesupport/CHANGELOG.md index 68036ede40b76..d8c624e06aeb1 100644 --- a/activesupport/CHANGELOG.md +++ b/activesupport/CHANGELOG.md @@ -1,3 +1,17 @@ +* Given an array of `Thread::Backtrace::Location` objects, the new method + `ActiveSupport::BacktraceCleaner#clean_locations` returns an array with the + clean ones: + + ```ruby + clean_locations = backtrace_cleaner.clean_locations(caller_locations) + ``` + + Filters and silencers receive strings as usual. However, the `path` + attributes of the locations in the returned array are the original, + unfiltered ones, since locations are immutable. + + *Xavier Noria* + * Improve `CurrentAttribute` and `ExecutionContext` state managment in test cases. Previously these two global state would be entirely cleared out whenever calling diff --git a/activesupport/lib/active_support/backtrace_cleaner.rb b/activesupport/lib/active_support/backtrace_cleaner.rb index e2415fa821b2c..a47852947f010 100644 --- a/activesupport/lib/active_support/backtrace_cleaner.rb +++ b/activesupport/lib/active_support/backtrace_cleaner.rb @@ -56,6 +56,18 @@ def clean(backtrace, kind = :silent) end alias :filter :clean + # Given an array of Thread::Backtrace::Location objects, returns an array + # with the clean ones: + # + # clean_locations = backtrace_cleaner.clean_locations(caller_locations) + # + # Filters and silencers receive strings as usual. However, the +path+ + # attributes of the locations in the returned array are the original, + # unfiltered ones, since locations are immutable. + def clean_locations(locations, kind = :silent) + locations.select { |location| clean_frame(location, kind) } + end + # Returns the frame with all filters applied. # returns +nil+ if the frame was silenced. def clean_frame(frame, kind = :silent) diff --git a/activesupport/test/backtrace_cleaner_test.rb b/activesupport/test/backtrace_cleaner_test.rb index 5e7a6ee8b920a..be0e26e4cbdb3 100644 --- a/activesupport/test/backtrace_cleaner_test.rb +++ b/activesupport/test/backtrace_cleaner_test.rb @@ -232,3 +232,48 @@ def invoke_first_clean_location(kind = :silent) assert_nil invoke_first_clean_location_defaults end end + +class BacktraceCleanerCleanLocationsTest < ActiveSupport::TestCase + def setup + @bc = ActiveSupport::BacktraceCleaner.new + @locations = indirect_caller_locations + end + + # Adds a frame from this file to the call stack. + def indirect_caller_locations + caller_locations + end + + test "returns all clean locations (defaults)" do + cleaned_locations = @bc.clean_locations(@locations) + assert_equal [__FILE__], cleaned_locations.map(&:path) + end + + test "returns all clean locations (:silent)" do + cleaned_locations = @bc.clean_locations(@locations, :silent) + assert_equal [__FILE__], cleaned_locations.map(&:path) + end + + test "returns all clean locations (:noise)" do + cleaned_locations = @bc.clean_locations(@locations, :noise) + assert_not_includes cleaned_locations.map(&:path), __FILE__ + end + + test "returns an empty array if there are no clean locations" do + @bc.add_silencer { true } + assert_equal [], @bc.clean_locations(@locations) + end + + test "filters and silencers are applied" do + @bc.remove_filters! + @bc.remove_silencers! + + # We filter all locations as "foo", then we silence filtered strings that + # are exactly "foo". If filters and silencers are correctly applied, we + # should get no locations back. + @bc.add_filter { "foo" } + @bc.add_silencer { "foo" == _1 } + + assert_equal [], @bc.clean_locations(@locations) + end +end From b51b272b9b110c9d1678fef1caba6e392c667018 Mon Sep 17 00:00:00 2001 From: Xavier Noria Date: Fri, 27 Jun 2025 17:33:58 +0200 Subject: [PATCH 0249/1075] Fixes typo in CANGELOG --- activesupport/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/activesupport/CHANGELOG.md b/activesupport/CHANGELOG.md index d8c624e06aeb1..fcef84c0bc120 100644 --- a/activesupport/CHANGELOG.md +++ b/activesupport/CHANGELOG.md @@ -57,7 +57,7 @@ other than their declared attribute, which isn't always the case, and can lead to state leak across request. - Now `CurrentAttribute` instances are abandonned at the end of a request, + Now `CurrentAttribute` instances are abandoned at the end of a request, and a new instance is created at the start of the next request. *Jean Boussier*, *Janko Marohnić* From d3e80a4380f5a7c2aabc9907a434827708b14bd7 Mon Sep 17 00:00:00 2001 From: Petrik Date: Fri, 27 Jun 2025 19:15:40 +0200 Subject: [PATCH 0250/1075] Correctly name `CurrentAttributes` in CHANGELOG [ci skip] The ActiveSupport::CurrentAttributes classname is always plural. --- activesupport/CHANGELOG.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/activesupport/CHANGELOG.md b/activesupport/CHANGELOG.md index fcef84c0bc120..67c251d149b9d 100644 --- a/activesupport/CHANGELOG.md +++ b/activesupport/CHANGELOG.md @@ -12,7 +12,7 @@ *Xavier Noria* -* Improve `CurrentAttribute` and `ExecutionContext` state managment in test cases. +* Improve `CurrentAttributes` and `ExecutionContext` state managment in test cases. Previously these two global state would be entirely cleared out whenever calling into code that is wrapped by the Rails executor, typically Action Controller or @@ -48,16 +48,16 @@ *Xavier Noria* -* Always clear `CurrentAttribute` instances. +* Always clear `CurrentAttributes` instances. - Previously `CurrentAttribute` instance would be reset at the end of requests. + Previously `CurrentAttributes` instance would be reset at the end of requests. Meaning its attributes would be re-initialized. This is problematic because it assume these objects don't hold any state other than their declared attribute, which isn't always the case, and can lead to state leak across request. - Now `CurrentAttribute` instances are abandoned at the end of a request, + Now `CurrentAttributes` instances are abandoned at the end of a request, and a new instance is created at the start of the next request. *Jean Boussier*, *Janko Marohnić* From ac7bd334725f673d3356ff8b103b6f19c32abe45 Mon Sep 17 00:00:00 2001 From: Henrik Nyh Date: Sat, 28 Jun 2025 12:34:47 +0100 Subject: [PATCH 0251/1075] [ci skip] Add warning note to :source_location tag option (#55257) Co-authored-by: fatkodima --- activerecord/lib/active_record/query_logs.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/activerecord/lib/active_record/query_logs.rb b/activerecord/lib/active_record/query_logs.rb index 513c6059e9089..cf127e3dd674d 100644 --- a/activerecord/lib/active_record/query_logs.rb +++ b/activerecord/lib/active_record/query_logs.rb @@ -28,6 +28,10 @@ module ActiveRecord # * +database+ # * +source_location+ # + # WARNING: Calculating the +source_location+ of a query can be slow, so you should consider its impact if using it in a production environment. + # + # Also see {config.active_record.verbose_query_logs}[https://guides.rubyonrails.org/debugging_rails_applications.html#verbose-query-logs]. + # # Action Controller adds default tags when loaded: # # * +controller+ From 8ca0f945808c8daf604c3fd9ea278137f86a219d Mon Sep 17 00:00:00 2001 From: Harriet Oughton <73295547+OughtPuts@users.noreply.github.com> Date: Sat, 28 Jun 2025 13:38:50 +0100 Subject: [PATCH 0252/1075] [RF-DOCS][ci skip] Update Threading and Code Execution Guide (#55179) This PR has been created to update the Threading and Code Execution Guide as part of the Rails Documentation project. ### Detail The following changes have been made: - Phrasing and language choices have been updated for improved clarity and simplicity. - The structure of the guide has been slightly altered to improve flow - Information about asynchronous Active Record has been added. - The 'work in progress' label has been removed. - Examples of wrapped application code have been added. - Information on Isolated Execution State has been added. - A section on `CurrentAttributes` has been added. --- guides/source/documents.yaml | 1 - guides/source/threading_and_code_execution.md | 371 +++++++++++++----- 2 files changed, 262 insertions(+), 110 deletions(-) diff --git a/guides/source/documents.yaml b/guides/source/documents.yaml index 395d453a92ee4..955c61e17772b 100644 --- a/guides/source/documents.yaml +++ b/guides/source/documents.yaml @@ -296,7 +296,6 @@ name: Threading and Code Execution in Rails url: threading_and_code_execution.html description: This guide describes the considerations needed and tools available when working directly with concurrency in a Rails application. - work_in_progress: true - name: Contributing documents: diff --git a/guides/source/threading_and_code_execution.md b/guides/source/threading_and_code_execution.md index 0771ba373e857..2e516bca4bffc 100644 --- a/guides/source/threading_and_code_execution.md +++ b/guides/source/threading_and_code_execution.md @@ -5,65 +5,145 @@ Threading and Code Execution in Rails After reading this guide, you will know: -* What code Rails will automatically execute concurrently -* How to integrate manual concurrency with Rails internals -* How to wrap all application code +* Where to find concurrent code execution in Rails +* How to integrate manual concurrency within Rails +* How to wrap application code using the Rails Executor * How to affect application reloading -------------------------------------------------------------------------------- -Automatic Concurrency ---------------------- +Built-in Concurrency in Rails +----------------------------- -Rails automatically allows various operations to be performed at the same time. +Rails automatically allows various operations to be performed at the same time +(concurrently) in order for an application to run more efficiently. In this +section, we will explore some of the ways this happens behind the scenes. -When using a threaded web server, such as the default Puma, multiple HTTP -requests will be served simultaneously, with each request provided its own -controller instance. +When using a threaded web server (such as Rails' default server, Puma) requests +will be served simultaneously as each request is given its own controller +instance. -Threaded Active Job adapters, including the built-in Async, will likewise -execute several jobs at the same time. Action Cable channels are managed this -way too. +Threaded Active Job adapters, including the built-in Async adapter, will +likewise execute several jobs at the same time. Action Cable channels are +managed this way too. -These mechanisms all involve multiple threads, each managing work for a unique -instance of some object (controller, job, channel), while sharing the global -process space (such as classes and their configurations, and global variables). -As long as your code doesn't modify any of those shared things, it can mostly -ignore that other threads exist. +Asynchronous Active Record queries are also performed in the background, +allowing other processes to run on the main thread. -The rest of this guide describes the mechanisms Rails uses to make it "mostly -ignorable", and how extensions and applications with special needs can use them. +The above mechanisms all involve multiple threads, each managing work for a +unique instance of an object (controller, job, channel), while sharing the +global process space (such as classes and their configurations, and global +variables). As long as the code on each thread doesn't modify anything shared, +multiple threads can safely run concurrently. -Executor --------- +Rails' built-in concurrency will cover the day-to-day needs of most application +developers, and ensure applications remain generally performant. -The Rails Executor separates application code from framework code: any time the -framework invokes code you've written in your application, it will be wrapped by -the Executor. +NOTE: You can read more about how to configure Rails' concurrency in the +[Framework Behavior](#framework-behavior) section. -The Executor consists of two callbacks: `to_run` and `to_complete`. The Run -callback is called before the application code, and the Complete callback is -called after. -### Default Callbacks +### Isolated Execution State -In a default Rails application, the Executor callbacks are used to: +The `active_support.isolation_level` value in your `configuration.rb` file +provides you the option to define where Rails' internal state should be stored +while tasks are run. If you use a fiber-based server or job processor (e.g. +[`falcon`](https://github.com/socketry/falcon)), you should set this value to +`:fiber`, otherwise it is best to set it to `:thread`. -* track which threads are in safe positions for autoloading and reloading -* enable and disable the Active Record query cache -* return acquired Active Record connections to the pool -* constrain internal cache lifetimes +Later sections of this guide detail advanced ways of wrapping code to ensure +thread safety, and how extensions and applications with particular concurrency +requirements, such as library maintainers, should do this. -Prior to Rails 5.0, some of these were handled by separate Rack middleware -classes (such as `ActiveRecord::ConnectionAdapters::ConnectionManagement`), or -directly wrapping code with methods like -`ActiveRecord::Base.connection_pool.with_connection`. The Executor replaces -these with a single more abstract interface. +Storing Thread-specific Data +---------------------------- -### Wrapping Application Code +The +[`ActiveSupport::CurrentAttributes`](https://edgeapi.rubyonrails.org/classes/ActiveSupport/CurrentAttributes.html) +class is a special class in Rails that helps you manage temporary, +thread-specific data for each request in your app, and helps make sure this data +is available to the whole system. It keeps this data separate for every request +(even if there are multiple threads running) and makes sure the data is cleaned +up automatically when the request is done. + +You can think of this class as a place to store data that you need to access +anywhere in your app without having to pass it around in your code. + +To use a `Current` class to store data, first you need to create a file as +shown in the example below, with `attribute` values for the attributes and +models whose values you would like to access throughout your application. You +can also define a method (e.g the `user` method below) which, when called, will +contain set values: + +```ruby +# app/models/current.rb +class Current < ActiveSupport::CurrentAttributes + attribute :account, :user + + resets { Time.zone = nil } + + def user=(user) + super + self.account = user.account + Time.zone = user.time_zone + end +end +``` + +You will now have access to `Current.user` elsewhere in your application. For +example, when authenticating a user: + +```ruby +# app/controllers/concerns/authentication.rb +module Authentication + extend ActiveSupport::Concern + + included do + before_action :authenticate + end + + private + def authenticate + if authenticated_user = User.find_by(id: cookies.encrypted[:user_id]) + Current.user = authenticated_user + else + redirect_to new_session_url + end + end +end +``` + +WARNING: It’s easy to put too many attributes in the `Current` class and tangle +your model as a result. `Current` should only be used for a few, top-level +globals, like account, user, and request details. + +Wrapping Application Code +------------------------- + +### The Rails Executor + +The Rails Executor inherits from the +[`ActiveSupport::ExecutionWrapper`](https://api.rubyonrails.org/classes/ActiveSupport/ExecutionWrapper.html). +The Executor separates application code from framework code by wrapping code +that you've written and is necessary when threads are being used. + +#### Callbacks + +The Executor consists of two callbacks: `to_run` and `to_complete`. The `to_run` +callback is called before the application code, and the `to_complete` callback +is called after. + +In a default Rails application, the Rails Executor callbacks are used to: + +* Track which threads are in safe positions for autoloading and reloading. +* Enable and disable the Active Record query cache. +* Return acquired Active Record connections to the pool. +* Constrain internal cache lifetimes. + +#### Code Execution If you're writing a library or component that will invoke application code, you -should wrap it with a call to the executor: +should wrap it with a call to the Executor: ```ruby Rails.application.executor.wrap do @@ -72,30 +152,30 @@ end ``` TIP: If you repeatedly invoke application code from a long-running process, you -may want to wrap using the [Reloader](#reloader) instead. +may want to wrap using the [Reloader](#the-reloader) instead. -Each thread should be wrapped before it runs application code, so if your -application manually delegates work to other threads, such as via `Thread.new` -or Concurrent Ruby features that use thread pools, you should immediately wrap -the block: +If your application manually delegates work to other threads, such as via +`Thread.new`, or uses features from the [Concurrent +Ruby](https://github.com/ruby-concurrency/concurrent-ruby) gem that use thread +pools, you should immediately wrap the block, before any application code is +run: ```ruby Thread.new do + # no code here Rails.application.executor.wrap do # your code here end end ``` -NOTE: Concurrent Ruby uses a `ThreadPoolExecutor`, which it sometimes configures -with an `executor` option. Despite the name, it is unrelated. +NOTE: The Concurrent Ruby gem uses a `ThreadPoolExecutor`, which it sometimes +configures with an `executor` option. Despite the name, it is _not_ related to +the Rails Executor. -The Executor is safely re-entrant; if it is already active on the current -thread, `wrap` is a no-op. - -If it's impractical to wrap the application code in a block (for -example, the Rack API makes this problematic), you can also use the `run!` / -`complete!` pair: +If it's impractical to wrap the application code in a block (for example, the +Rack API makes this problematic), you can also use the `run!` / `complete!` +pair: ```ruby Thread.new do @@ -106,21 +186,75 @@ ensure end ``` -### Concurrency +NOTE: The Rails Executor is safely re-entrant; it can be called again if it is +already running. In this case, the `wrap` method has no effect. + +#### Running Mode + +When called, the Rails Executor will put the current thread into `running` mode +in the [Load Interlock](#load-interlock). + +This operation will block temporarily if another thread is currently either +autoloading a constant or unloading/reloading the application. + +#### Examples of Wrapped Application Code + +Any time your library or component needs to invoke code that will run in the +application, the code should be wrapped to ensure thread safety and a consistent +and clean runtime state. + +For example, you may be setting a `Current` user (using +[`ActiveSupport::CurrentAttributes`](#storing-thread-specific-data)). + +```ruby +def log_with_user_context(message) + Rails.application.executor.wrap do + Current.user = User.find_by(id: 1) + end +end +``` + +You may be triggering an Active Record callback or lifecycle hook in an +application: + +```ruby +def perform_task_with_record(record) + Rails.application.executor.wrap do + record.save! # Executes before_save, after_save, etc. + end +end +``` + +Or enqueuing or performing a background job within the application: + +```ruby +def enqueue_background_job(job_class, *args) + Rails.application.executor.wrap do + job_class.perform_later(*args) + end +end +``` + +These are just a few of many possible use cases, including rendering views or +templates, broadcasting via [`Action Cable`](action_cable_overview.html) or +using [`Rails.cache`](caching_with_rails.html). -The Executor will put the current thread into `running` mode in the [Load -Interlock](#load-interlock). This operation will block temporarily if another -thread is currently either autoloading a constant or unloading/reloading -the application. +### The Reloader -Reloader --------- +Like the Executor, the +[Reloader](https://api.rubyonrails.org/classes/ActiveSupport/Reloader.html) also +wraps application code. The Reloader is only suitable where a long-running +framework-level process repeatedly calls into application code, such as for a +web server or job queue. -Like the Executor, the Reloader also wraps application code. If the Executor is -not already active on the current thread, the Reloader will invoke it for you, -so you only need to call one. This also guarantees that everything the Reloader -does, including all its callback invocations, occurs wrapped inside the -Executor. +NOTE: Rails automatically wraps web requests and Active Job workers, so you'll +rarely need to invoke the Reloader for yourself. Always consider whether the +Executor is a better fit for your use case. + +If the Executor is not already active on the current thread, the Reloader will +invoke it for you, so you only need to call one. This also guarantees that +everything the Reloader does, including all its callback executions, occurs +wrapped inside the Executor. ```ruby Rails.application.reloader.wrap do @@ -128,38 +262,49 @@ Rails.application.reloader.wrap do end ``` -The Reloader is only suitable where a long-running framework-level process -repeatedly calls into application code, such as for a web server or job queue. -Rails automatically wraps web requests and Active Job workers, so you'll rarely -need to invoke the Reloader for yourself. Always consider whether the Executor -is a better fit for your use case. - -### Callbacks +#### Callbacks Before entering the wrapped block, the Reloader will check whether the running -application needs to be reloaded -- for example, because a model's source file has -been modified. If it determines a reload is required, it will wait until it's -safe, and then do so, before continuing. When the application is configured to -always reload regardless of whether any changes are detected, the reload is -instead performed at the end of the block. +application needs to be reloaded (because a model's source file has been +modified, for example). If it determines a reload is required, it will wait +until it's safe, and then do so, before continuing. When the application is +configured to always reload regardless of whether any changes are detected, the +reload is instead performed at the end of the block. The Reloader also provides `to_run` and `to_complete` callbacks; they are invoked at the same points as those of the Executor, but only when the current execution has initiated an application reload. When no reload is deemed necessary, the Reloader will invoke the wrapped block with no other callbacks. -### Class Unload +```ruby +Rails.application.reloader.to_run do + # call reloading code here +end +``` + +#### Class Unload -The most significant part of the reloading process is the Class Unload, where +The most significant part of the reloading process is the "class unload", where all autoloaded classes are removed, ready to be loaded again. This will occur -immediately before either the Run or Complete callback, depending on the -`reload_classes_only_on_change` setting. +immediately before either the `to_run` or `to_complete` callback, depending on +the +[`reload_classes_only_on_change`](configuring.html#config-reload-classes-only-on-change) +setting. Often, additional reloading actions need to be performed either just before or -just after the Class Unload, so the Reloader also provides `before_class_unload` -and `after_class_unload` callbacks. +just after the "class unload", so the Reloader also provides +[`before_class_unload`](https://api.rubyonrails.org/classes/ActiveSupport/Reloader.html#method-c-before_class_unload) +and +[`after_class_unload`](https://api.rubyonrails.org/classes/ActiveSupport/Reloader.html#method-c-after_class_unload) +callbacks. + +```ruby +Rails.application.reloader.before_class_unload do + # call class unloading code here +end +``` -### Concurrency +#### Concurrency Only long-running "top level" processes should invoke the Reloader, because if it determines a reload is needed, it will block until all other threads have @@ -173,36 +318,42 @@ thread is mid-execution. Child threads should use the Executor instead. Framework Behavior ------------------ -The Rails framework components use these tools to manage their own concurrency -needs too. +The Rails framework components use the Executor and the Reloader to manage their +own concurrency needs too. -`ActionDispatch::Executor` and `ActionDispatch::Reloader` are Rack middlewares -that wrap requests with a supplied Executor or Reloader, respectively. They -are automatically included in the default application stack. The Reloader will -ensure any arriving HTTP request is served with a freshly-loaded copy of the -application if any code changes have occurred. +[`ActionDispatch::Executor`](https://api.rubyonrails.org/classes/ActionDispatch/Executor.html) +and +[`ActionDispatch::Reloader`](https://api.rubyonrails.org/classes/ActionDispatch/Reloader.html) +are Rack middlewares that wrap requests with a supplied Executor or Reloader, +respectively. They are automatically included in the default application stack. +The Reloader will ensure any arriving HTTP request is served with a +freshly-loaded copy of the application if any code changes have occurred. Active Job also wraps its job executions with the Reloader, loading the latest code to execute each job as it comes off the queue. -Action Cable uses the Executor instead: because a Cable connection is linked to -a specific instance of a class, it's not possible to reload for every arriving -WebSocket message. Only the message handler is wrapped, though; a long-running -Cable connection does not prevent a reload that's triggered by a new incoming -request or job. Instead, Action Cable uses the Reloader's `before_class_unload` -callback to disconnect all its connections. When the client automatically -reconnects, it will be speaking to the new version of the code. +Action Cable uses the Executor instead. A Cable connection is linked to a +specific instance of a class, which means it's not possible to reload for every +arriving WebSocket message. Only the message handler is wrapped, though; a +long-running Cable connection does not prevent a reload that's triggered by a +new incoming request or job. Instead, Action Cable also uses the Reloader's +`before_class_unload` callback to disconnect all its connections. When the +client automatically reconnects, it will be interacting with the new version of +the code. The above are the entry points to the framework, so they are responsible for ensuring their respective threads are protected, and deciding whether a reload -is necessary. Other components only need to use the Executor when they spawn -additional threads. +is necessary. Most other components only need to use the Executor when they +spawn additional threads. ### Configuration -The Reloader only checks for file changes when `config.enable_reloading` is -`true` and so is `config.reload_classes_only_on_change`. These are the defaults in the -`development` environment. +#### Reloader and Executor Configuration + +The Reloader only checks for file changes when +[`config.enable_reloading`](configuring.html#config-enable-reloading) and +[`config.reload_classes_only_on_change`](configuring.html#config-reload-classes-only-on-change) +are both `true`. These are the defaults in the `development` environment. When `config.enable_reloading` is `false` (in `production`, by default), the Reloader is only a pass-through to the Executor. @@ -210,9 +361,11 @@ Reloader is only a pass-through to the Executor. The Executor always has important work to do, like database connection management. When `config.enable_reloading` is `false` and `config.eager_load` is `true` (`production` defaults), no reloading will occur, so it does not need the -Load Interlock. With the default settings in the `development` environment, the -Executor will use the Load Interlock to ensure constants are only loaded when it -is safe. +[Load Interlock](#load-interlock). With the default settings in the +`development` environment, the Executor will use the Load Interlock to ensure +constants are only loaded when it is safe. + +You can read more about the Load Interlock in the following section. Load Interlock -------------- @@ -247,7 +400,7 @@ block, and autoload knows when to upgrade to a `load` lock, and switch back to Other blocking operations performed inside the Executor block (which includes all application code), however, can needlessly retain the `running` lock. If -another thread encounters a constant it must autoload, this can cause a +another thread encounters a constant it must autoload, which can cause a deadlock. For example, assuming `User` is not yet loaded, the following will deadlock: @@ -320,5 +473,5 @@ interlock, which lock level they are holding or awaiting, and their current backtrace. Generally a deadlock will be caused by the interlock conflicting with some other -external lock or blocking I/O call. Once you find it, you can wrap it with -`permit_concurrent_loads`. +external lock or blocking input/output call. Once you find it, you can wrap it +with `permit_concurrent_loads`. From dd20e840d5965e090a941e1b401f81dbf1ab29ab Mon Sep 17 00:00:00 2001 From: Lucas Dohmen Date: Mon, 30 Jun 2025 09:51:24 +0200 Subject: [PATCH 0253/1075] Rails New: Only add browser restrictions when using importmap This commit changes the generator for new Rails applications to only add: ```ruby allow_browser versions: :modern ``` If the application is generated with `--javascript=importmap`. This is the default, so this PR does not change anything for people that do not override the JavaScript option. --- railties/CHANGELOG.md | 4 ++++ .../templates/app/controllers/application_controller.rb.tt | 4 +--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/railties/CHANGELOG.md b/railties/CHANGELOG.md index a8db37e567f78..4f83ee127e6e7 100644 --- a/railties/CHANGELOG.md +++ b/railties/CHANGELOG.md @@ -104,4 +104,8 @@ *Petrik de Heus* +* Only add browser restrictions for a new Rails app when using importmap. + + *Lucas Dohmen* + Please check [8-0-stable](https://github.com/rails/rails/blob/8-0-stable/railties/CHANGELOG.md) for previous changes. diff --git a/railties/lib/rails/generators/rails/app/templates/app/controllers/application_controller.rb.tt b/railties/lib/rails/generators/rails/app/templates/app/controllers/application_controller.rb.tt index ce0ef8d515c41..32547dd16b0f7 100644 --- a/railties/lib/rails/generators/rails/app/templates/app/controllers/application_controller.rb.tt +++ b/railties/lib/rails/generators/rails/app/templates/app/controllers/application_controller.rb.tt @@ -1,11 +1,9 @@ class ApplicationController < ActionController::<%= options.api? ? "API" : "Base" %> -<%- unless options.api? -%> +<%- if using_importmap? -%> # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has. allow_browser versions: :modern -<%- if using_importmap? -%> # Changes to the importmap will invalidate the etag for HTML responses stale_when_importmap_changes <% end -%> -<% end -%> end From f5c755e5a79c608291530e9d7661690e8ad068fb Mon Sep 17 00:00:00 2001 From: Petrik Date: Mon, 30 Jun 2025 08:59:42 +0200 Subject: [PATCH 0254/1075] Revert "[RF-DOCS][ci skip] Update Threading and Code Execution Guide (#55179)" The threading guide rewrite "seems to be less accurate, and at points misleading". This reverts commit 8ca0f945808c8daf604c3fd9ea278137f86a219d. --- guides/source/documents.yaml | 1 + guides/source/threading_and_code_execution.md | 371 +++++------------- 2 files changed, 110 insertions(+), 262 deletions(-) diff --git a/guides/source/documents.yaml b/guides/source/documents.yaml index 955c61e17772b..395d453a92ee4 100644 --- a/guides/source/documents.yaml +++ b/guides/source/documents.yaml @@ -296,6 +296,7 @@ name: Threading and Code Execution in Rails url: threading_and_code_execution.html description: This guide describes the considerations needed and tools available when working directly with concurrency in a Rails application. + work_in_progress: true - name: Contributing documents: diff --git a/guides/source/threading_and_code_execution.md b/guides/source/threading_and_code_execution.md index 2e516bca4bffc..0771ba373e857 100644 --- a/guides/source/threading_and_code_execution.md +++ b/guides/source/threading_and_code_execution.md @@ -5,145 +5,65 @@ Threading and Code Execution in Rails After reading this guide, you will know: -* Where to find concurrent code execution in Rails -* How to integrate manual concurrency within Rails -* How to wrap application code using the Rails Executor +* What code Rails will automatically execute concurrently +* How to integrate manual concurrency with Rails internals +* How to wrap all application code * How to affect application reloading -------------------------------------------------------------------------------- -Built-in Concurrency in Rails ------------------------------ +Automatic Concurrency +--------------------- -Rails automatically allows various operations to be performed at the same time -(concurrently) in order for an application to run more efficiently. In this -section, we will explore some of the ways this happens behind the scenes. +Rails automatically allows various operations to be performed at the same time. -When using a threaded web server (such as Rails' default server, Puma) requests -will be served simultaneously as each request is given its own controller -instance. +When using a threaded web server, such as the default Puma, multiple HTTP +requests will be served simultaneously, with each request provided its own +controller instance. -Threaded Active Job adapters, including the built-in Async adapter, will -likewise execute several jobs at the same time. Action Cable channels are -managed this way too. +Threaded Active Job adapters, including the built-in Async, will likewise +execute several jobs at the same time. Action Cable channels are managed this +way too. -Asynchronous Active Record queries are also performed in the background, -allowing other processes to run on the main thread. +These mechanisms all involve multiple threads, each managing work for a unique +instance of some object (controller, job, channel), while sharing the global +process space (such as classes and their configurations, and global variables). +As long as your code doesn't modify any of those shared things, it can mostly +ignore that other threads exist. -The above mechanisms all involve multiple threads, each managing work for a -unique instance of an object (controller, job, channel), while sharing the -global process space (such as classes and their configurations, and global -variables). As long as the code on each thread doesn't modify anything shared, -multiple threads can safely run concurrently. +The rest of this guide describes the mechanisms Rails uses to make it "mostly +ignorable", and how extensions and applications with special needs can use them. -Rails' built-in concurrency will cover the day-to-day needs of most application -developers, and ensure applications remain generally performant. +Executor +-------- -NOTE: You can read more about how to configure Rails' concurrency in the -[Framework Behavior](#framework-behavior) section. +The Rails Executor separates application code from framework code: any time the +framework invokes code you've written in your application, it will be wrapped by +the Executor. +The Executor consists of two callbacks: `to_run` and `to_complete`. The Run +callback is called before the application code, and the Complete callback is +called after. -### Isolated Execution State +### Default Callbacks -The `active_support.isolation_level` value in your `configuration.rb` file -provides you the option to define where Rails' internal state should be stored -while tasks are run. If you use a fiber-based server or job processor (e.g. -[`falcon`](https://github.com/socketry/falcon)), you should set this value to -`:fiber`, otherwise it is best to set it to `:thread`. +In a default Rails application, the Executor callbacks are used to: -Later sections of this guide detail advanced ways of wrapping code to ensure -thread safety, and how extensions and applications with particular concurrency -requirements, such as library maintainers, should do this. +* track which threads are in safe positions for autoloading and reloading +* enable and disable the Active Record query cache +* return acquired Active Record connections to the pool +* constrain internal cache lifetimes -Storing Thread-specific Data ----------------------------- +Prior to Rails 5.0, some of these were handled by separate Rack middleware +classes (such as `ActiveRecord::ConnectionAdapters::ConnectionManagement`), or +directly wrapping code with methods like +`ActiveRecord::Base.connection_pool.with_connection`. The Executor replaces +these with a single more abstract interface. -The -[`ActiveSupport::CurrentAttributes`](https://edgeapi.rubyonrails.org/classes/ActiveSupport/CurrentAttributes.html) -class is a special class in Rails that helps you manage temporary, -thread-specific data for each request in your app, and helps make sure this data -is available to the whole system. It keeps this data separate for every request -(even if there are multiple threads running) and makes sure the data is cleaned -up automatically when the request is done. - -You can think of this class as a place to store data that you need to access -anywhere in your app without having to pass it around in your code. - -To use a `Current` class to store data, first you need to create a file as -shown in the example below, with `attribute` values for the attributes and -models whose values you would like to access throughout your application. You -can also define a method (e.g the `user` method below) which, when called, will -contain set values: - -```ruby -# app/models/current.rb -class Current < ActiveSupport::CurrentAttributes - attribute :account, :user - - resets { Time.zone = nil } - - def user=(user) - super - self.account = user.account - Time.zone = user.time_zone - end -end -``` - -You will now have access to `Current.user` elsewhere in your application. For -example, when authenticating a user: - -```ruby -# app/controllers/concerns/authentication.rb -module Authentication - extend ActiveSupport::Concern - - included do - before_action :authenticate - end - - private - def authenticate - if authenticated_user = User.find_by(id: cookies.encrypted[:user_id]) - Current.user = authenticated_user - else - redirect_to new_session_url - end - end -end -``` - -WARNING: It’s easy to put too many attributes in the `Current` class and tangle -your model as a result. `Current` should only be used for a few, top-level -globals, like account, user, and request details. - -Wrapping Application Code -------------------------- - -### The Rails Executor - -The Rails Executor inherits from the -[`ActiveSupport::ExecutionWrapper`](https://api.rubyonrails.org/classes/ActiveSupport/ExecutionWrapper.html). -The Executor separates application code from framework code by wrapping code -that you've written and is necessary when threads are being used. - -#### Callbacks - -The Executor consists of two callbacks: `to_run` and `to_complete`. The `to_run` -callback is called before the application code, and the `to_complete` callback -is called after. - -In a default Rails application, the Rails Executor callbacks are used to: - -* Track which threads are in safe positions for autoloading and reloading. -* Enable and disable the Active Record query cache. -* Return acquired Active Record connections to the pool. -* Constrain internal cache lifetimes. - -#### Code Execution +### Wrapping Application Code If you're writing a library or component that will invoke application code, you -should wrap it with a call to the Executor: +should wrap it with a call to the executor: ```ruby Rails.application.executor.wrap do @@ -152,30 +72,30 @@ end ``` TIP: If you repeatedly invoke application code from a long-running process, you -may want to wrap using the [Reloader](#the-reloader) instead. +may want to wrap using the [Reloader](#reloader) instead. -If your application manually delegates work to other threads, such as via -`Thread.new`, or uses features from the [Concurrent -Ruby](https://github.com/ruby-concurrency/concurrent-ruby) gem that use thread -pools, you should immediately wrap the block, before any application code is -run: +Each thread should be wrapped before it runs application code, so if your +application manually delegates work to other threads, such as via `Thread.new` +or Concurrent Ruby features that use thread pools, you should immediately wrap +the block: ```ruby Thread.new do - # no code here Rails.application.executor.wrap do # your code here end end ``` -NOTE: The Concurrent Ruby gem uses a `ThreadPoolExecutor`, which it sometimes -configures with an `executor` option. Despite the name, it is _not_ related to -the Rails Executor. +NOTE: Concurrent Ruby uses a `ThreadPoolExecutor`, which it sometimes configures +with an `executor` option. Despite the name, it is unrelated. -If it's impractical to wrap the application code in a block (for example, the -Rack API makes this problematic), you can also use the `run!` / `complete!` -pair: +The Executor is safely re-entrant; if it is already active on the current +thread, `wrap` is a no-op. + +If it's impractical to wrap the application code in a block (for +example, the Rack API makes this problematic), you can also use the `run!` / +`complete!` pair: ```ruby Thread.new do @@ -186,75 +106,21 @@ ensure end ``` -NOTE: The Rails Executor is safely re-entrant; it can be called again if it is -already running. In this case, the `wrap` method has no effect. - -#### Running Mode - -When called, the Rails Executor will put the current thread into `running` mode -in the [Load Interlock](#load-interlock). - -This operation will block temporarily if another thread is currently either -autoloading a constant or unloading/reloading the application. - -#### Examples of Wrapped Application Code - -Any time your library or component needs to invoke code that will run in the -application, the code should be wrapped to ensure thread safety and a consistent -and clean runtime state. - -For example, you may be setting a `Current` user (using -[`ActiveSupport::CurrentAttributes`](#storing-thread-specific-data)). - -```ruby -def log_with_user_context(message) - Rails.application.executor.wrap do - Current.user = User.find_by(id: 1) - end -end -``` - -You may be triggering an Active Record callback or lifecycle hook in an -application: - -```ruby -def perform_task_with_record(record) - Rails.application.executor.wrap do - record.save! # Executes before_save, after_save, etc. - end -end -``` - -Or enqueuing or performing a background job within the application: - -```ruby -def enqueue_background_job(job_class, *args) - Rails.application.executor.wrap do - job_class.perform_later(*args) - end -end -``` - -These are just a few of many possible use cases, including rendering views or -templates, broadcasting via [`Action Cable`](action_cable_overview.html) or -using [`Rails.cache`](caching_with_rails.html). +### Concurrency -### The Reloader +The Executor will put the current thread into `running` mode in the [Load +Interlock](#load-interlock). This operation will block temporarily if another +thread is currently either autoloading a constant or unloading/reloading +the application. -Like the Executor, the -[Reloader](https://api.rubyonrails.org/classes/ActiveSupport/Reloader.html) also -wraps application code. The Reloader is only suitable where a long-running -framework-level process repeatedly calls into application code, such as for a -web server or job queue. +Reloader +-------- -NOTE: Rails automatically wraps web requests and Active Job workers, so you'll -rarely need to invoke the Reloader for yourself. Always consider whether the -Executor is a better fit for your use case. - -If the Executor is not already active on the current thread, the Reloader will -invoke it for you, so you only need to call one. This also guarantees that -everything the Reloader does, including all its callback executions, occurs -wrapped inside the Executor. +Like the Executor, the Reloader also wraps application code. If the Executor is +not already active on the current thread, the Reloader will invoke it for you, +so you only need to call one. This also guarantees that everything the Reloader +does, including all its callback invocations, occurs wrapped inside the +Executor. ```ruby Rails.application.reloader.wrap do @@ -262,49 +128,38 @@ Rails.application.reloader.wrap do end ``` -#### Callbacks +The Reloader is only suitable where a long-running framework-level process +repeatedly calls into application code, such as for a web server or job queue. +Rails automatically wraps web requests and Active Job workers, so you'll rarely +need to invoke the Reloader for yourself. Always consider whether the Executor +is a better fit for your use case. + +### Callbacks Before entering the wrapped block, the Reloader will check whether the running -application needs to be reloaded (because a model's source file has been -modified, for example). If it determines a reload is required, it will wait -until it's safe, and then do so, before continuing. When the application is -configured to always reload regardless of whether any changes are detected, the -reload is instead performed at the end of the block. +application needs to be reloaded -- for example, because a model's source file has +been modified. If it determines a reload is required, it will wait until it's +safe, and then do so, before continuing. When the application is configured to +always reload regardless of whether any changes are detected, the reload is +instead performed at the end of the block. The Reloader also provides `to_run` and `to_complete` callbacks; they are invoked at the same points as those of the Executor, but only when the current execution has initiated an application reload. When no reload is deemed necessary, the Reloader will invoke the wrapped block with no other callbacks. -```ruby -Rails.application.reloader.to_run do - # call reloading code here -end -``` - -#### Class Unload +### Class Unload -The most significant part of the reloading process is the "class unload", where +The most significant part of the reloading process is the Class Unload, where all autoloaded classes are removed, ready to be loaded again. This will occur -immediately before either the `to_run` or `to_complete` callback, depending on -the -[`reload_classes_only_on_change`](configuring.html#config-reload-classes-only-on-change) -setting. +immediately before either the Run or Complete callback, depending on the +`reload_classes_only_on_change` setting. Often, additional reloading actions need to be performed either just before or -just after the "class unload", so the Reloader also provides -[`before_class_unload`](https://api.rubyonrails.org/classes/ActiveSupport/Reloader.html#method-c-before_class_unload) -and -[`after_class_unload`](https://api.rubyonrails.org/classes/ActiveSupport/Reloader.html#method-c-after_class_unload) -callbacks. - -```ruby -Rails.application.reloader.before_class_unload do - # call class unloading code here -end -``` +just after the Class Unload, so the Reloader also provides `before_class_unload` +and `after_class_unload` callbacks. -#### Concurrency +### Concurrency Only long-running "top level" processes should invoke the Reloader, because if it determines a reload is needed, it will block until all other threads have @@ -318,42 +173,36 @@ thread is mid-execution. Child threads should use the Executor instead. Framework Behavior ------------------ -The Rails framework components use the Executor and the Reloader to manage their -own concurrency needs too. +The Rails framework components use these tools to manage their own concurrency +needs too. -[`ActionDispatch::Executor`](https://api.rubyonrails.org/classes/ActionDispatch/Executor.html) -and -[`ActionDispatch::Reloader`](https://api.rubyonrails.org/classes/ActionDispatch/Reloader.html) -are Rack middlewares that wrap requests with a supplied Executor or Reloader, -respectively. They are automatically included in the default application stack. -The Reloader will ensure any arriving HTTP request is served with a -freshly-loaded copy of the application if any code changes have occurred. +`ActionDispatch::Executor` and `ActionDispatch::Reloader` are Rack middlewares +that wrap requests with a supplied Executor or Reloader, respectively. They +are automatically included in the default application stack. The Reloader will +ensure any arriving HTTP request is served with a freshly-loaded copy of the +application if any code changes have occurred. Active Job also wraps its job executions with the Reloader, loading the latest code to execute each job as it comes off the queue. -Action Cable uses the Executor instead. A Cable connection is linked to a -specific instance of a class, which means it's not possible to reload for every -arriving WebSocket message. Only the message handler is wrapped, though; a -long-running Cable connection does not prevent a reload that's triggered by a -new incoming request or job. Instead, Action Cable also uses the Reloader's -`before_class_unload` callback to disconnect all its connections. When the -client automatically reconnects, it will be interacting with the new version of -the code. +Action Cable uses the Executor instead: because a Cable connection is linked to +a specific instance of a class, it's not possible to reload for every arriving +WebSocket message. Only the message handler is wrapped, though; a long-running +Cable connection does not prevent a reload that's triggered by a new incoming +request or job. Instead, Action Cable uses the Reloader's `before_class_unload` +callback to disconnect all its connections. When the client automatically +reconnects, it will be speaking to the new version of the code. The above are the entry points to the framework, so they are responsible for ensuring their respective threads are protected, and deciding whether a reload -is necessary. Most other components only need to use the Executor when they -spawn additional threads. +is necessary. Other components only need to use the Executor when they spawn +additional threads. ### Configuration -#### Reloader and Executor Configuration - -The Reloader only checks for file changes when -[`config.enable_reloading`](configuring.html#config-enable-reloading) and -[`config.reload_classes_only_on_change`](configuring.html#config-reload-classes-only-on-change) -are both `true`. These are the defaults in the `development` environment. +The Reloader only checks for file changes when `config.enable_reloading` is +`true` and so is `config.reload_classes_only_on_change`. These are the defaults in the +`development` environment. When `config.enable_reloading` is `false` (in `production`, by default), the Reloader is only a pass-through to the Executor. @@ -361,11 +210,9 @@ Reloader is only a pass-through to the Executor. The Executor always has important work to do, like database connection management. When `config.enable_reloading` is `false` and `config.eager_load` is `true` (`production` defaults), no reloading will occur, so it does not need the -[Load Interlock](#load-interlock). With the default settings in the -`development` environment, the Executor will use the Load Interlock to ensure -constants are only loaded when it is safe. - -You can read more about the Load Interlock in the following section. +Load Interlock. With the default settings in the `development` environment, the +Executor will use the Load Interlock to ensure constants are only loaded when it +is safe. Load Interlock -------------- @@ -400,7 +247,7 @@ block, and autoload knows when to upgrade to a `load` lock, and switch back to Other blocking operations performed inside the Executor block (which includes all application code), however, can needlessly retain the `running` lock. If -another thread encounters a constant it must autoload, which can cause a +another thread encounters a constant it must autoload, this can cause a deadlock. For example, assuming `User` is not yet loaded, the following will deadlock: @@ -473,5 +320,5 @@ interlock, which lock level they are holding or awaiting, and their current backtrace. Generally a deadlock will be caused by the interlock conflicting with some other -external lock or blocking input/output call. Once you find it, you can wrap it -with `permit_concurrent_loads`. +external lock or blocking I/O call. Once you find it, you can wrap it with +`permit_concurrent_loads`. From f7d6e480332f85aa74bdb0f8ece7bec2581e2224 Mon Sep 17 00:00:00 2001 From: fatkodima Date: Mon, 30 Jun 2025 21:27:22 +0300 Subject: [PATCH 0255/1075] Fix `annotate` comments to propagate to `update_all`/`delete_all` --- activerecord/lib/arel/crud.rb | 2 ++ activerecord/lib/arel/delete_manager.rb | 5 +++++ activerecord/lib/arel/nodes/delete_statement.rb | 6 ++++-- activerecord/lib/arel/nodes/update_statement.rb | 6 ++++-- activerecord/lib/arel/select_manager.rb | 8 ++++++-- activerecord/lib/arel/update_manager.rb | 5 +++++ activerecord/lib/arel/visitors/dot.rb | 2 ++ activerecord/lib/arel/visitors/postgresql.rb | 1 + activerecord/lib/arel/visitors/sqlite.rb | 1 + activerecord/lib/arel/visitors/to_sql.rb | 2 ++ activerecord/test/cases/relation_test.rb | 16 ++++++++++++++++ 11 files changed, 48 insertions(+), 6 deletions(-) diff --git a/activerecord/lib/arel/crud.rb b/activerecord/lib/arel/crud.rb index f4d60b775e781..defae45e18167 100644 --- a/activerecord/lib/arel/crud.rb +++ b/activerecord/lib/arel/crud.rb @@ -21,6 +21,7 @@ def compile_update(values, key = nil) um.offset(offset) um.order(*orders) um.wheres = constraints + um.comment(comment) um.key = key um.ast.groups = @ctx.groups @@ -34,6 +35,7 @@ def compile_delete(key = nil) dm.offset(offset) dm.order(*orders) dm.wheres = constraints + dm.comment(comment) dm.key = key dm.ast.groups = @ctx.groups @ctx.havings.each { |h| dm.having(h) } diff --git a/activerecord/lib/arel/delete_manager.rb b/activerecord/lib/arel/delete_manager.rb index bf7f26f589605..5877a2e45d7ad 100644 --- a/activerecord/lib/arel/delete_manager.rb +++ b/activerecord/lib/arel/delete_manager.rb @@ -28,5 +28,10 @@ def having(expr) @ast.havings << expr self end + + def comment(value) + @ast.comment = value + self + end end end diff --git a/activerecord/lib/arel/nodes/delete_statement.rb b/activerecord/lib/arel/nodes/delete_statement.rb index a9a1babb02658..80875fa2eea79 100644 --- a/activerecord/lib/arel/nodes/delete_statement.rb +++ b/activerecord/lib/arel/nodes/delete_statement.rb @@ -3,7 +3,7 @@ module Arel # :nodoc: all module Nodes class DeleteStatement < Arel::Nodes::Node - attr_accessor :relation, :wheres, :groups, :havings, :orders, :limit, :offset, :key + attr_accessor :relation, :wheres, :groups, :havings, :orders, :limit, :offset, :comment, :key def initialize(relation = nil, wheres = []) super() @@ -14,6 +14,7 @@ def initialize(relation = nil, wheres = []) @orders = [] @limit = nil @offset = nil + @comment = nil @key = nil end @@ -24,7 +25,7 @@ def initialize_copy(other) end def hash - [self.class, @relation, @wheres, @orders, @limit, @offset, @key].hash + [self.class, @relation, @wheres, @orders, @limit, @offset, @comment, @key].hash end def eql?(other) @@ -36,6 +37,7 @@ def eql?(other) self.havings == other.havings && self.limit == other.limit && self.offset == other.offset && + self.comment == other.comment && self.key == other.key end alias :== :eql? diff --git a/activerecord/lib/arel/nodes/update_statement.rb b/activerecord/lib/arel/nodes/update_statement.rb index 6cd25cb558236..0e9938ceef210 100644 --- a/activerecord/lib/arel/nodes/update_statement.rb +++ b/activerecord/lib/arel/nodes/update_statement.rb @@ -3,7 +3,7 @@ module Arel # :nodoc: all module Nodes class UpdateStatement < Arel::Nodes::Node - attr_accessor :relation, :wheres, :values, :groups, :havings, :orders, :limit, :offset, :key + attr_accessor :relation, :wheres, :values, :groups, :havings, :orders, :limit, :offset, :comment, :key def initialize(relation = nil) super() @@ -15,6 +15,7 @@ def initialize(relation = nil) @orders = [] @limit = nil @offset = nil + @comment = nil @key = nil end @@ -25,7 +26,7 @@ def initialize_copy(other) end def hash - [@relation, @wheres, @values, @orders, @limit, @offset, @key].hash + [@relation, @wheres, @values, @orders, @limit, @offset, @comment, @key].hash end def eql?(other) @@ -38,6 +39,7 @@ def eql?(other) self.orders == other.orders && self.limit == other.limit && self.offset == other.offset && + self.comment == other.comment && self.key == other.key end alias :== :eql? diff --git a/activerecord/lib/arel/select_manager.rb b/activerecord/lib/arel/select_manager.rb index 28014fdcf72a3..dd004b273abb6 100644 --- a/activerecord/lib/arel/select_manager.rb +++ b/activerecord/lib/arel/select_manager.rb @@ -255,8 +255,12 @@ def source end def comment(*values) - @ctx.comment = Nodes::Comment.new(values) - self + if values.any? + @ctx.comment = Nodes::Comment.new(values) + self + else + @ctx.comment + end end private diff --git a/activerecord/lib/arel/update_manager.rb b/activerecord/lib/arel/update_manager.rb index 01951c3a4348e..ee86aa30c5dc2 100644 --- a/activerecord/lib/arel/update_manager.rb +++ b/activerecord/lib/arel/update_manager.rb @@ -45,5 +45,10 @@ def having(expr) @ast.havings << expr self end + + def comment(value) + @ast.comment = value + self + end end end diff --git a/activerecord/lib/arel/visitors/dot.rb b/activerecord/lib/arel/visitors/dot.rb index fb163de46217d..d80d4be9acf11 100644 --- a/activerecord/lib/arel/visitors/dot.rb +++ b/activerecord/lib/arel/visitors/dot.rb @@ -150,6 +150,7 @@ def visit_Arel_Nodes_UpdateStatement(o) visit_edge o, "orders" visit_edge o, "limit" visit_edge o, "offset" + visit_edge o, "comment" visit_edge o, "key" end @@ -159,6 +160,7 @@ def visit_Arel_Nodes_DeleteStatement(o) visit_edge o, "orders" visit_edge o, "limit" visit_edge o, "offset" + visit_edge o, "comment" visit_edge o, "key" end diff --git a/activerecord/lib/arel/visitors/postgresql.rb b/activerecord/lib/arel/visitors/postgresql.rb index b28e4c0bd4b34..7603e6a53bdee 100644 --- a/activerecord/lib/arel/visitors/postgresql.rb +++ b/activerecord/lib/arel/visitors/postgresql.rb @@ -29,6 +29,7 @@ def visit_Arel_Nodes_UpdateStatement(o, collector) collect_nodes_for o.wheres, collector, " WHERE ", " AND " collect_nodes_for o.orders, collector, " ORDER BY " maybe_visit o.limit, collector + maybe_visit o.comment, collector end # In the simple case, PostgreSQL allows us to place FROM or JOINs directly into the UPDATE diff --git a/activerecord/lib/arel/visitors/sqlite.rb b/activerecord/lib/arel/visitors/sqlite.rb index a1a2f6662af04..733e0bf64eca2 100644 --- a/activerecord/lib/arel/visitors/sqlite.rb +++ b/activerecord/lib/arel/visitors/sqlite.rb @@ -29,6 +29,7 @@ def visit_Arel_Nodes_UpdateStatement(o, collector) collect_nodes_for o.wheres, collector, " WHERE ", " AND " collect_nodes_for o.orders, collector, " ORDER BY " maybe_visit o.limit, collector + maybe_visit o.comment, collector end def prepare_update_statement(o) diff --git a/activerecord/lib/arel/visitors/to_sql.rb b/activerecord/lib/arel/visitors/to_sql.rb index 8fde22b2eb1c4..4e5b495b4b2d7 100644 --- a/activerecord/lib/arel/visitors/to_sql.rb +++ b/activerecord/lib/arel/visitors/to_sql.rb @@ -35,6 +35,7 @@ def visit_Arel_Nodes_DeleteStatement(o, collector) collect_nodes_for o.wheres, collector, " WHERE ", " AND " collect_nodes_for o.orders, collector, " ORDER BY " maybe_visit o.limit, collector + maybe_visit o.comment, collector end def visit_Arel_Nodes_UpdateStatement(o, collector) @@ -48,6 +49,7 @@ def visit_Arel_Nodes_UpdateStatement(o, collector) collect_nodes_for o.wheres, collector, " WHERE ", " AND " collect_nodes_for o.orders, collector, " ORDER BY " maybe_visit o.limit, collector + maybe_visit o.comment, collector end def visit_Arel_Nodes_InsertStatement(o, collector) diff --git a/activerecord/test/cases/relation_test.rb b/activerecord/test/cases/relation_test.rb index 31e3d502aa95b..77ab9a9b5144b 100644 --- a/activerecord/test/cases/relation_test.rb +++ b/activerecord/test/cases/relation_test.rb @@ -375,6 +375,22 @@ def test_relation_with_annotation_includes_comment_in_count_query end end + def test_relation_with_annotation_includes_comment_in_update_all_query + post_with_annotation = Post.annotate("foo") + all_count = Post.all.to_a.count + assert_queries_match(%r{/\* foo \*/}) do + assert_equal all_count, post_with_annotation.update_all(title: "Same title") + end + end + + def test_relation_with_annotation_includes_comment_in_delete_all_query + post_with_annotation = Post.annotate("foo") + all_count = Post.all.to_a.count + assert_queries_match(%r{/\* foo \*/}) do + assert_equal all_count, post_with_annotation.delete_all + end + end + def test_relation_without_annotation_does_not_include_an_empty_comment log = capture_sql do Post.where(id: 1).first From 516526f9620e3354d3aa2486359cebb7ffd19233 Mon Sep 17 00:00:00 2001 From: zzak Date: Tue, 1 Jul 2025 10:43:56 +0900 Subject: [PATCH 0256/1075] Fix use_big_decimal_serializer flag --- guides/source/7_2_release_notes.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/guides/source/7_2_release_notes.md b/guides/source/7_2_release_notes.md index 3d883c2a37b17..5c75f992f9d5c 100644 --- a/guides/source/7_2_release_notes.md +++ b/guides/source/7_2_release_notes.md @@ -567,7 +567,7 @@ Please refer to the [Changelog][active-job] for detailed changes. ### Deprecations -* Deprecate `Rails.application.config.active_job.use_big_decimal_serialize`. +* Deprecate `Rails.application.config.active_job.use_big_decimal_serializer`. ### Notable changes From c32f48d80cd9dda10db13561c35c5572ba92a072 Mon Sep 17 00:00:00 2001 From: Yasuo Honda Date: Mon, 30 Jun 2025 23:09:18 +0900 Subject: [PATCH 0257/1075] Revert workaround from #55182 and update to latest devcontainer image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit updates the devcontainer image to ghcr.io/rails/devcontainer/images/ruby:3.4.4 , reflecting the latest version from rails/devcontainer@ruby-2.0.0. It also reverts the temporary workaround introduced in #55182, which used an older image to avoid issues caused by the transition from rbenv to mise. - With this commit ``` vscode ➜ /workspaces/rails (use_mise_in_devcontainer) $ ruby -v ruby 3.4.4 (2025-05-14 revision a38531fd3f) +PRISM [aarch64-linux] vscode ➜ /workspaces/rails (use_mise_in_devcontainer) $ which ruby /home/vscode/.local/share/mise/installs/ruby/3.4.4/bin/ruby vscode ➜ /workspaces/rails (use_mise_in_devcontainer) $ ``` In the updated image, Ruby is no longer installed via rbenv, so /home/vscode/.rbenv/shims/bundle no longer exists. - Using `ghcr.io/rails/devcontainer/images/ruby:3.4.4` image without changing the path to bundler ``` => ERROR [dev_container_auto_added_stage_label 19/19] RUN cd /tmp/rails 0.1s ------ > [dev_container_auto_added_stage_label 19/19] RUN cd /tmp/rails && /home/vscode/.rbenv/shims/bundle install && rm -rf /tmp/rails: 0.127 /bin/sh: 1: /home/vscode/.rbenv/shims/bundle: not found ------ ``` Rather than hard-coding a mise-specific path, this change runs bundle install in an interactive Bash shell (bash -i -c) to ensure that mise’s environment setup in ~/.bashrc is applied. This avoids introducing a direct dependency on mise internals and ensures compatibility with future changes to the Ruby installation mechanism. By default, Docker’s RUN instruction uses the shell form ["/bin/sh", "-c"], as documented in the Dockerfile reference. https://docs.docker.com/reference/dockerfile/#shell > The default shell on Linux is ["/bin/sh", "-c"], This shell does not source ~/.bashrc, so any environment configuration added by mise would be ignored. To address this, bash -i -c is explicitly used to invoke an interactive shell. According to the GNU Bash manual, interactive non-login shells source ~/.bashrc by default. https://www.gnu.org/software/bash/manual/bash.html#Bash-Startup-Files > Invoked as an interactive non-login shell > When an interactive shell that is not a login shell is started, > Bash reads and executes commands from ~/.bashrc, if that file exists. --- .devcontainer/Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index fc5380bfd5dab..d26804637a0b7 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,7 +1,7 @@ # See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.191.1/containers/ruby/.devcontainer/base.Dockerfile # [Choice] Ruby version: 3.4, 3.3, 3.2 -ARG VARIANT="1.1.3-3.4.4" +ARG VARIANT="3.4.4" FROM ghcr.io/rails/devcontainer/images/ruby:${VARIANT} RUN sudo apt-get update && export DEBIAN_FRONTEND=noninteractive \ @@ -34,5 +34,5 @@ COPY tools/releaser/releaser.gemspec /tmp/rails/tools/releaser/ RUN sudo chown -R vscode:vscode /tmp/rails USER vscode RUN cd /tmp/rails \ - && /home/vscode/.rbenv/shims/bundle install \ + && bash -i -c 'bundle install' \ && rm -rf /tmp/rails From 228e954bb375b05dc43c04593875034d6130ab07 Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Tue, 1 Jul 2025 09:32:15 +0200 Subject: [PATCH 0258/1075] Improve XmlMini date parsing The TODO was suggesting to use a regexp, but `Date.strptime` is the fastest and most strict solution. Co-Authored-By: Erik Berlin --- activesupport/lib/active_support/xml_mini.rb | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/activesupport/lib/active_support/xml_mini.rb b/activesupport/lib/active_support/xml_mini.rb index 380d0156b685e..2c2b8185b1b80 100644 --- a/activesupport/lib/active_support/xml_mini.rb +++ b/activesupport/lib/active_support/xml_mini.rb @@ -62,11 +62,10 @@ def content_type "yaml" => Proc.new { |yaml| yaml.to_yaml } } unless defined?(FORMATTING) - # TODO use regexp instead of Date.parse unless defined?(PARSING) PARSING = { "symbol" => Proc.new { |symbol| symbol.to_s.to_sym }, - "date" => Proc.new { |date| ::Date.parse(date) }, + "date" => Proc.new { |date| ::Date.strptime(date, "%Y-%m-%d") }, "datetime" => Proc.new { |time| Time.xmlschema(time).utc rescue ::DateTime.parse(time).utc }, "duration" => Proc.new { |duration| Duration.parse(duration) }, "integer" => Proc.new { |integer| integer.to_i }, From f7762f4820ec790860c4cbe259b359f1fc77ef40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wojciech=20Wn=C4=99trzak?= Date: Tue, 1 Jul 2025 09:41:05 +0200 Subject: [PATCH 0259/1075] Add quotations for consistency with other envs Follow up to https://github.com/rails/rails/pull/55252 --- railties/lib/rails/generators/rails/app/templates/Dockerfile.tt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/railties/lib/rails/generators/rails/app/templates/Dockerfile.tt b/railties/lib/rails/generators/rails/app/templates/Dockerfile.tt index b807b820393c1..2659ec2828759 100644 --- a/railties/lib/rails/generators/rails/app/templates/Dockerfile.tt +++ b/railties/lib/rails/generators/rails/app/templates/Dockerfile.tt @@ -25,7 +25,7 @@ ENV RAILS_ENV="production" \ BUNDLE_DEPLOYMENT="1" \ BUNDLE_PATH="/usr/local/bundle" \ BUNDLE_WITHOUT="development" \ - LD_PRELOAD=/usr/local/lib/libjemalloc.so + LD_PRELOAD="/usr/local/lib/libjemalloc.so" # Throw-away build stage to reduce size of final image FROM base AS build From c8e485651fedc093ab150b7fd43cbeed3ed59549 Mon Sep 17 00:00:00 2001 From: "Ben Sheldon [he/him]" Date: Tue, 17 Jun 2025 15:15:32 -0700 Subject: [PATCH 0260/1075] Add `dom_target` helper to create `dom_id`-like strings from an unlimited number of objects --- actionview/CHANGELOG.md | 5 +++++ .../lib/action_view/record_identifier.rb | 21 +++++++++++++++++++ .../test/template/record_identifier_test.rb | 20 ++++++++++++++++++ 3 files changed, 46 insertions(+) diff --git a/actionview/CHANGELOG.md b/actionview/CHANGELOG.md index eb5f3c14eaeff..d42ab69a64b63 100644 --- a/actionview/CHANGELOG.md +++ b/actionview/CHANGELOG.md @@ -1,3 +1,8 @@ +* Add `dom_target` helper to create `dom_id`-like strings from an unlimited + number of objects. + + *Ben Sheldon* + * Respect `html_options[:form]` when `collection_checkboxes` generates the hidden ``. diff --git a/actionview/lib/action_view/record_identifier.rb b/actionview/lib/action_view/record_identifier.rb index 6796997cdbb89..46e2c214a465a 100644 --- a/actionview/lib/action_view/record_identifier.rb +++ b/actionview/lib/action_view/record_identifier.rb @@ -101,6 +101,27 @@ def dom_id(record_or_class, prefix = nil) end end + # The DOM target convention is to concatenate any number of parameters into a string. + # Records are passed through dom_id, while string and symbols are retained. + # + # dom_target(Post.find(45)) # => "post_45" + # dom_target(Post.find(45), :edit) # => "post_45_edit" + # dom_target(Post.find(45), :edit, :special) # => "post_45_edit_special" + # dom_target(Post.find(45), Comment.find(1)) # => "post_45_comment_1" + def dom_target(*objects) + objects.map! do |object| + case object + when Symbol, String + object + when Class + dom_class(object) + else + dom_id(object) + end + end + objects.join(JOIN) + end + private # Returns a string representation of the key attribute(s) that is suitable for use in an HTML DOM id. # This can be overwritten to customize the default generated string representation if desired. diff --git a/actionview/test/template/record_identifier_test.rb b/actionview/test/template/record_identifier_test.rb index 78d1b3d8e0942..250db03d36e15 100644 --- a/actionview/test/template/record_identifier_test.rb +++ b/actionview/test/template/record_identifier_test.rb @@ -56,6 +56,16 @@ def test_dom_id_as_singleton_method def test_dom_class_as_singleton_method assert_equal @singular, ActionView::RecordIdentifier.dom_class(@record) end + + def test_dom_target_with_multiple_objects + @record.save + assert_equal "foo_bar_comment_comment_1_new_comment", dom_target(:foo, "bar", @klass, @record, @klass.new) + end + + def test_dom_target_as_singleton_method + @record.save + assert_equal "#{@singular}_#{@record.id}", ActionView::RecordIdentifier.dom_target(@record) + end end class RecordIdentifierWithoutActiveModelTest < ActiveSupport::TestCase @@ -110,4 +120,14 @@ def test_dom_id_as_singleton_method def test_dom_class_as_singleton_method assert_equal "airplane", ActionView::RecordIdentifier.dom_class(@record) end + + def test_dom_target_with_multiple_objects + @record.save + assert_equal "foo_bar_airplane_airplane_1_new_airplane", dom_target(:foo, "bar", @klass, @record, @klass.new) + end + + def test_dom_target_as_singleton_method + @record.save + assert_equal "airplane_1", ActionView::RecordIdentifier.dom_target(@record) + end end From 489e08d75767d7394e2124eb60f76f18ac69728c Mon Sep 17 00:00:00 2001 From: zzak Date: Tue, 20 May 2025 15:48:20 +0900 Subject: [PATCH 0261/1075] Fix `remove_foreign_key` when multiple columns are found Co-authored-by: fatkodima --- .../abstract/schema_statements.rb | 15 +++++- .../sqlite3/schema_statements.rb | 16 ++---- .../active_record/migration/compatibility.rb | 23 ++++++++ .../cases/migration/compatibility_test.rb | 54 +++++++++++++++++++ .../test/cases/migration/foreign_key_test.rb | 48 +++++++++++++++++ 5 files changed, 142 insertions(+), 14 deletions(-) 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 9579b8e5aa003..9c0dce749b3e5 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/schema_statements.rb @@ -1815,7 +1815,20 @@ def foreign_key_name(table_name, options) def foreign_key_for(from_table, **options) return unless use_foreign_keys? - foreign_keys(from_table).detect { |fk| fk.defined_for?(**options) } + + keys = foreign_keys(from_table) + + if options[:_skip_column_match] + return keys.find { |fk| fk.defined_for?(**options) } + end + + if options[:column].nil? + default_column = foreign_key_column_for(options[:to_table], "id") + matches = keys.select { |fk| fk.column == default_column } + keys = matches if matches.any? + end + + keys.find { |fk| fk.defined_for?(**options) } end def foreign_key_for!(from_table, to_table: nil, **options) 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 8348ef67075ec..47057af62cf23 100644 --- a/activerecord/lib/active_record/connection_adapters/sqlite3/schema_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/sqlite3/schema_statements.rb @@ -63,23 +63,13 @@ def add_foreign_key(from_table, to_table, **options) end def remove_foreign_key(from_table, to_table = nil, **options) - return if options.delete(:if_exists) == true && !foreign_key_exists?(from_table, to_table, **options.slice(:column)) + return if options.delete(:if_exists) && !foreign_key_exists?(from_table, to_table, **options.slice(:column)) to_table ||= options[:to_table] options = options.except(:name, :to_table, :validate) - foreign_keys = foreign_keys(from_table) - - fkey = foreign_keys.detect do |fk| - table = to_table || begin - table = options[:column].to_s.delete_suffix("_id") - Base.pluralize_table_names ? table.pluralize : table - end - table = strip_table_name_prefix_and_suffix(table) - options = options.slice(*fk.options.keys) - fk_to_table = strip_table_name_prefix_and_suffix(fk.to_table) - fk_to_table == table && options.all? { |k, v| fk.options[k].to_s == v.to_s } - end || raise(ArgumentError, "Table '#{from_table}' has no foreign key for #{to_table || options}") + fkey = foreign_key_for!(from_table, to_table: to_table, **options) + foreign_keys = foreign_keys(from_table) foreign_keys.delete(fkey) alter_table(from_table, foreign_keys) end diff --git a/activerecord/lib/active_record/migration/compatibility.rb b/activerecord/lib/active_record/migration/compatibility.rb index 31e2a3cf9bce1..dad2911b34e10 100644 --- a/activerecord/lib/active_record/migration/compatibility.rb +++ b/activerecord/lib/active_record/migration/compatibility.rb @@ -32,6 +32,29 @@ def self.find(version) V8_1 = Current class V8_0 < V8_1 + module RemoveForeignKeyColumnMatch + def remove_foreign_key(from_table, to_table = nil, **options) + options[:_skip_column_match] = true + super + end + end + + module TableDefinition + def remove_foreign_key(to_table = nil, **options) + options[:_skip_column_match] = true + super + end + end + + include RemoveForeignKeyColumnMatch + + private + def compatible_table_definition(t) + class << t + prepend TableDefinition + end + super + end end class V7_2 < V8_0 diff --git a/activerecord/test/cases/migration/compatibility_test.rb b/activerecord/test/cases/migration/compatibility_test.rb index 835ea9400c710..8ba3ce672e577 100644 --- a/activerecord/test/cases/migration/compatibility_test.rb +++ b/activerecord/test/cases/migration/compatibility_test.rb @@ -664,6 +664,60 @@ def up end end + def test_remove_foreign_key_on_8_1 + connection.create_table(:sub_testings) do |t| + t.references :testing, foreign_key: true, type: :bigint + t.references :experiment, foreign_key: { to_table: :testings }, type: :bigint + end + + migration = Class.new(ActiveRecord::Migration[8.1]) do + def up + change_table(:sub_testings) do |t| + t.remove_foreign_key :testings + t.remove_foreign_key :testings, column: :experiment_id + end + end + end + + ActiveRecord::Migrator.new(:up, [migration], @schema_migration, @internal_metadata).migrate + + foreign_keys = @connection.foreign_keys("sub_testings") + assert_equal 0, foreign_keys.size + ensure + connection.drop_table(:sub_testings, if_exists: true) + ActiveRecord::Base.clear_cache! + end + + def test_remove_foreign_key_on_8_0 + connection.create_table(:sub_testings) do |t| + t.references :testing, foreign_key: true, type: :bigint + t.references :experiment, foreign_key: { to_table: :testings }, type: :bigint + end + + migration = Class.new(ActiveRecord::Migration[8.0]) do + def up + change_table(:sub_testings) do |t| + t.remove_foreign_key :testings + t.remove_foreign_key :testings, column: :experiment_id + end + end + end + + assert_raise(StandardError, match: /Table 'sub_testings' has no foreign key for testings$/) { + ActiveRecord::Migrator.new(:up, [migration], @schema_migration, @internal_metadata).migrate + } + + foreign_keys = @connection.foreign_keys("sub_testings") + if current_adapter?(:PostgreSQLAdapter, :SQLite3Adapter) + assert_equal 2, foreign_keys.size + else + assert_equal 1, foreign_keys.size + end + ensure + connection.drop_table(:sub_testings, if_exists: true) + ActiveRecord::Base.clear_cache! + end + private def precision_implicit_default if current_adapter?(:Mysql2Adapter, :TrilogyAdapter) diff --git a/activerecord/test/cases/migration/foreign_key_test.rb b/activerecord/test/cases/migration/foreign_key_test.rb index f1f308f30f15b..8c23930bae6a5 100644 --- a/activerecord/test/cases/migration/foreign_key_test.rb +++ b/activerecord/test/cases/migration/foreign_key_test.rb @@ -260,6 +260,22 @@ def test_add_foreign_key_with_if_not_exists_considers_primary_key_option assert_equal ["id", "id_for_type_change"], foreign_keys.map(&:primary_key).sort end + def test_remove_foreign_key_with_multiple_keys_between_tables + @connection.add_column :astronauts, :backup_rocket_id, :bigint + + @connection.add_foreign_key :astronauts, :rockets, column: :rocket_id + @connection.add_foreign_key :astronauts, :rockets, column: :backup_rocket_id + + @connection.remove_foreign_key :astronauts, :rockets + + foreign_keys = @connection.foreign_keys(:astronauts) + assert_equal 1, foreign_keys.size + assert_equal "backup_rocket_id", foreign_keys.first.column + + @connection.remove_foreign_key :astronauts, :rockets, column: :backup_rocket_id + assert_empty @connection.foreign_keys(:astronauts) + end + def test_add_foreign_key_with_non_standard_primary_key @connection.create_table :space_shuttles, id: false, force: true do |t| t.bigint :pk, primary_key: true @@ -771,6 +787,38 @@ def test_add_foreign_key_with_suffix ActiveRecord::Base.table_name_suffix = nil end + def test_remove_foreign_key_with_prefix + ActiveRecord::Base.table_name_prefix = "p_" + + migration = CreateSchoolsAndClassesMigration.new + silence_stream($stdout) { migration.migrate(:up) } + assert_equal 1, @connection.foreign_keys("p_classes").size + + @connection.remove_foreign_key :p_classes + + assert_empty @connection.foreign_keys("p_classes") + ensure + @connection.drop_table :p_classes + @connection.drop_table :p_schools + ActiveRecord::Base.table_name_prefix = nil + end + + def test_remove_foreign_key_with_suffix + ActiveRecord::Base.table_name_suffix = "_s" + + migration = CreateSchoolsAndClassesMigration.new + silence_stream($stdout) { migration.migrate(:up) } + assert_equal 1, @connection.foreign_keys("classes_s").size + + @connection.remove_foreign_key :classes_s + + assert_empty @connection.foreign_keys("classes_s") + ensure + @connection.drop_table :classes_s + @connection.drop_table :schools_s + ActiveRecord::Base.table_name_suffix = nil + end + def test_remove_foreign_key_with_if_exists_not_set @connection.add_foreign_key :astronauts, :rockets assert_equal 1, @connection.foreign_keys("astronauts").size From 1a6d67b51f55dabf44c9751f868987c06be13e1b Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Tue, 1 Jul 2025 10:06:02 +0200 Subject: [PATCH 0262/1075] Cleanup `compatible_table_definition` methods No need to use `class <<`. --- .../active_record/migration/compatibility.rb | 32 +++++-------------- 1 file changed, 8 insertions(+), 24 deletions(-) diff --git a/activerecord/lib/active_record/migration/compatibility.rb b/activerecord/lib/active_record/migration/compatibility.rb index dad2911b34e10..77fad5b1bb6ed 100644 --- a/activerecord/lib/active_record/migration/compatibility.rb +++ b/activerecord/lib/active_record/migration/compatibility.rb @@ -50,9 +50,7 @@ def remove_foreign_key(to_table = nil, **options) private def compatible_table_definition(t) - class << t - prepend TableDefinition - end + t.singleton_class.prepend(TableDefinition) super end end @@ -180,9 +178,7 @@ def add_foreign_key(from_table, to_table, **options) private def compatible_table_definition(t) - class << t - prepend TableDefinition - end + t.singleton_class.prepend(TableDefinition) super end end @@ -243,9 +239,7 @@ def raise_on_if_exist_options(options) private def compatible_table_definition(t) - class << t - prepend TableDefinition - end + t.singleton_class.prepend(TableDefinition) super end end @@ -286,9 +280,7 @@ def add_reference(table_name, ref_name, **options) private def compatible_table_definition(t) - class << t - prepend TableDefinition - end + t.singleton_class.prepend(TableDefinition) super end end @@ -334,17 +326,13 @@ def add_timestamps(table_name, **options) private def compatible_table_definition(t) - class << t - prepend TableDefinition - end + t.singleton_class.prepend(TableDefinition) super end def command_recorder recorder = super - class << recorder - prepend CommandRecorder - end + recorder.singleton_class.prepend(CommandRecorder) recorder end end @@ -432,9 +420,7 @@ def add_reference(table_name, ref_name, **options) private def compatible_table_definition(t) - class << t - prepend TableDefinition - end + t.singleton_class.prepend(TableDefinition) super end end @@ -486,9 +472,7 @@ def remove_index(table_name, column_name = nil, **options) private def compatible_table_definition(t) - class << t - prepend TableDefinition - end + t.singleton_class.prepend(TableDefinition) super end From c2c7e20a28db867f2ed478def48109c0b1235b7d Mon Sep 17 00:00:00 2001 From: Cedric Pimenta Date: Tue, 1 Jul 2025 10:08:05 +0100 Subject: [PATCH 0263/1075] Declare block param on NilClass#try and NilClass#try! (#55278) With ruby 3.4, a warning is raised when a block is passed to a method and the method does not define the block param. This commit solves it by ensuring that the method signature of try and try! on NilClass is similar to Tryable. --- activesupport/lib/active_support/core_ext/object/try.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/activesupport/lib/active_support/core_ext/object/try.rb b/activesupport/lib/active_support/core_ext/object/try.rb index c2c76254ae4c9..5a8d546386f62 100644 --- a/activesupport/lib/active_support/core_ext/object/try.rb +++ b/activesupport/lib/active_support/core_ext/object/try.rb @@ -145,14 +145,14 @@ class NilClass # # With +try+ # @person.try(:children).try(:first).try(:name) - def try(*) + def try(*, &) nil end # Calling +try!+ on +nil+ always returns +nil+. # # nil.try!(:name) # => nil - def try!(*) + def try!(*, &) nil end end From ed7e06196970ee674f10f4ba7257959943f361bb Mon Sep 17 00:00:00 2001 From: Csaba Apagyi Date: Thu, 26 Jun 2025 20:21:31 +0200 Subject: [PATCH 0264/1075] [Fix #53683] Reduce cache time for non-asset files in public dir --- .../templates/config/environments/production.rb.tt | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/railties/lib/rails/generators/rails/app/templates/config/environments/production.rb.tt b/railties/lib/rails/generators/rails/app/templates/config/environments/production.rb.tt index 5ac606b8a3173..f1832c841905b 100644 --- a/railties/lib/rails/generators/rails/app/templates/config/environments/production.rb.tt +++ b/railties/lib/rails/generators/rails/app/templates/config/environments/production.rb.tt @@ -17,8 +17,17 @@ Rails.application.configure do config.action_controller.perform_caching = true <%- end -%> - # Cache assets for far-future expiry since they are all digest stamped. - config.public_file_server.headers = { "cache-control" => "public, max-age=#{1.year.to_i}" } + # Cache digest stamped assets for far-future expiry. + # Short cache for others: robots.txt, sitemap.xml, 404.html, etc. + config.public_file_server.headers = { + "cache-control" => lambda do |path, _| + if path.start_with?("/assets/") + "public, max-age=#{1.year.to_i}" + else + "public, max-age=#{1.minute.to_i}" + end + end + } # Enable serving of images, stylesheets, and JavaScripts from an asset server. # config.asset_host = "http://assets.example.com" From 6b5a125fd8e05619c5cd11698a145ca59384fb09 Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Tue, 1 Jul 2025 12:45:56 +0200 Subject: [PATCH 0265/1075] Improve default public file server cache-control further Set `immutable` for digested assets, this improve modern browsers behavior, saves them from ever revalidating. Set `stale-while-revalidate` for non-digested files. This allows caching proxies to ever only revalidate at most once per minute and asyncrhonously. --- .../app/templates/config/environments/production.rb.tt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/railties/lib/rails/generators/rails/app/templates/config/environments/production.rb.tt b/railties/lib/rails/generators/rails/app/templates/config/environments/production.rb.tt index f1832c841905b..0117292a33e2f 100644 --- a/railties/lib/rails/generators/rails/app/templates/config/environments/production.rb.tt +++ b/railties/lib/rails/generators/rails/app/templates/config/environments/production.rb.tt @@ -22,9 +22,12 @@ Rails.application.configure do config.public_file_server.headers = { "cache-control" => lambda do |path, _| if path.start_with?("/assets/") - "public, max-age=#{1.year.to_i}" + # Files in /assets/ are expected to be fully immutable. + # If the content change the URL too. + "public, immutable, max-age=#{1.year.to_i}" else - "public, max-age=#{1.minute.to_i}" + # For anything else we cache for 1 minute. + "public, max-age=#{1.minute.to_i}, stale-while-revalidate=#{5.minutes.to_i}" end end } From fbd6755e4bee1fc811ca895d220b69258dfb4c76 Mon Sep 17 00:00:00 2001 From: Xavier Noria Date: Tue, 1 Jul 2025 23:06:15 +0200 Subject: [PATCH 0266/1075] Implement deprecated associations --- activerecord/CHANGELOG.md | 16 ++ activerecord/lib/active_record.rb | 24 ++ .../lib/active_record/associations.rb | 132 ++++++++++ .../associations/builder/association.rb | 21 +- .../associations/builder/belongs_to.rb | 16 +- .../builder/collection_association.rb | 8 +- .../builder/singular_association.rb | 38 ++- .../active_record/associations/deprecation.rb | 82 ++++++ .../lib/active_record/associations/errors.rb | 3 + .../associations/join_dependency.rb | 2 + .../associations/preloader/branch.rb | 1 + activerecord/lib/active_record/errors.rb | 3 + .../lib/active_record/nested_attributes.rb | 2 + activerecord/lib/active_record/railtie.rb | 1 + activerecord/lib/active_record/reflection.rb | 36 +++ .../belongs_to_associations_test.rb | 172 +++++++++++++ .../cases/associations/deprecation_test.rb | 238 ++++++++++++++++++ ...s_and_belongs_to_many_associations_test.rb | 59 +++++ .../has_many_associations_test.rb | 75 +++++- .../has_many_through_associations_test.rb | 55 ++++ .../associations/has_one_associations_test.rb | 130 ++++++++++ .../has_one_through_associations_test.rb | 55 ++++ activerecord/test/cases/errors_test.rb | 6 +- .../test/cases/nested_attributes_test.rb | 40 +++ activerecord/test/cases/reflection_test.rb | 51 ++++ activerecord/test/cases/relations_test.rb | 228 +++++++++++++++++ activerecord/test/models/dats.rb | 18 ++ activerecord/test/models/dats/author.rb | 31 +++ .../test/models/dats/author_favorite.rb | 5 + activerecord/test/models/dats/bulb.rb | 7 + activerecord/test/models/dats/car.rb | 16 ++ activerecord/test/models/dats/category.rb | 9 + activerecord/test/models/dats/comment.rb | 6 + activerecord/test/models/dats/post.rb | 17 ++ activerecord/test/models/dats/tyre.rb | 5 + .../deprecated_associations_test_helpers.rb | 51 ++++ .../source/active_support_instrumentation.md | 17 ++ guides/source/association_basics.md | 11 + guides/source/configuring.md | 25 ++ .../test/application/configuration_test.rb | 19 ++ 40 files changed, 1715 insertions(+), 16 deletions(-) create mode 100644 activerecord/lib/active_record/associations/deprecation.rb create mode 100644 activerecord/test/cases/associations/deprecation_test.rb create mode 100644 activerecord/test/models/dats.rb create mode 100644 activerecord/test/models/dats/author.rb create mode 100644 activerecord/test/models/dats/author_favorite.rb create mode 100644 activerecord/test/models/dats/bulb.rb create mode 100644 activerecord/test/models/dats/car.rb create mode 100644 activerecord/test/models/dats/category.rb create mode 100644 activerecord/test/models/dats/comment.rb create mode 100644 activerecord/test/models/dats/post.rb create mode 100644 activerecord/test/models/dats/tyre.rb create mode 100644 activerecord/test/support/deprecated_associations_test_helpers.rb diff --git a/activerecord/CHANGELOG.md b/activerecord/CHANGELOG.md index d3c96f03d4482..4dad4f8e8f5a4 100644 --- a/activerecord/CHANGELOG.md +++ b/activerecord/CHANGELOG.md @@ -1,3 +1,19 @@ +* Implement support for deprecating associations: + + ```ruby + has_many :posts, deprecated: true + ``` + + With that, Active Record will report any usage of the `posts` association. + + Three reporting modes are supported (`:warn`, `:raise`, and `:notify`), and + backtraces can be enabled or disabled. Defaults are `:warn` mode and + disabled backtraces. + + Please, check the docs for further details. + + *Xavier Noria* + * PostgreSQL adapter create DB now supports `locale_provider` and `locale`. *Bengt-Ove Hollaender* diff --git a/activerecord/lib/active_record.rb b/activerecord/lib/active_record.rb index a9350f5bfae0c..cd1f0ce510119 100644 --- a/activerecord/lib/active_record.rb +++ b/activerecord/lib/active_record.rb @@ -26,6 +26,7 @@ require "active_support" require "active_support/rails" require "active_support/ordered_options" +require "active_support/core_ext/array/conversions" require "active_model" require "arel" require "yaml" @@ -470,6 +471,29 @@ def self.permanent_connection_checkout=(value) singleton_class.attr_accessor :generate_secure_token_on self.generate_secure_token_on = :create + def self.deprecated_associations_options=(options) + raise ArgumentError, "deprecated_associations_options must be a hash" unless options.is_a?(Hash) + + valid_keys = [:mode, :backtrace] + + invalid_keys = options.keys - valid_keys + unless invalid_keys.empty? + inflected_key = invalid_keys.size == 1 ? "key" : "keys" + raise ArgumentError, "invalid deprecated_associations_options #{inflected_key} #{invalid_keys.map(&:inspect).to_sentence} (valid keys are #{valid_keys.map(&:inspect).to_sentence})" + end + + options.each do |key, value| + ActiveRecord::Associations::Deprecation.send("#{key}=", value) + end + end + + def self.deprecated_associations_options + { + mode: ActiveRecord::Associations::Deprecation.mode, + backtrace: ActiveRecord::Associations::Deprecation.backtrace + } + end + 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 f48b9106d118b..bbf7ae123869d 100644 --- a/activerecord/lib/active_record/associations.rb +++ b/activerecord/lib/active_record/associations.rb @@ -39,6 +39,8 @@ module Builder # :nodoc: autoload :AssociationScope autoload :DisableJoinsAssociationScope autoload :AliasTracker + + autoload :Deprecation end def self.eager_load! @@ -87,6 +89,14 @@ def association_instance_set(name, association) @association_cache[name] = association end + def deprecated_associations_api_guard(association, method_name) + Deprecation.guard(association.reflection) { "the method #{method_name} was invoked" } + end + + def report_deprecated_association(reflection, context:) + Deprecation.report(reflection, context: context) + end + # = Active Record \Associations # # \Associations are a set of macro-like class methods for tying objects together through @@ -1020,6 +1030,116 @@ def association_instance_set(name, association) # associated records themselves, you can always do something along the lines of # person.tasks.each(&:destroy). # + # == Deprecated Associations + # + # Associations can be marked as deprecated by passing deprecated: true: + # + # has_many :posts, deprecated: true + # + # When a deprecated association is used, a warning is issued using the + # Active Record logger, though more options are available via + # configuration. + # + # The message includes some context that helps understand the deprecated + # usage: + # + # The association Author#posts is deprecated, the method post_ids was invoked (...) + # The association Author#posts is deprecated, referenced in query to preload records (...) + # + # The dots in the examples above would have the application-level spot + # where usage occurred, to help locate what triggered the warning. That + # location is computed using the Active Record backtrace cleaner. + # + # === What is considered to be usage? + # + # * Invocation of any association methods like +posts+, posts=, + # etc. + # + # * If the association accepts nested attributes, assignment to those + # attributes. + # + # * If the association is a through association and some of its nested + # associations are deprecated, you'll get warnings for them whenever the + # top-level through is used. This is so regardless of whether the + # through itself is deprecated. + # + # * Execution of queries that refer to the association. Think execution of + # eager_load(:posts), joins(author: :posts), etc. + # + # * If the association has a +:dependent+ option, destroying the + # associated record issues warnings (because that has a side-effect that + # would not happen if the association was removed). + # + # * If the association has a +:touch+ option, saving or destroying the + # record issues a warning (because that has a side-effect that would not + # happen if the association was removed). + # + # === Things that do NOT issue warnings + # + # The rationale behind most of the following edge cases is that Active + # Record accesses associations lazily, when used. Before that, the + # reference to the association is basically just a Ruby symbol. + # + # * If +posts+ is deprecated, has_many :comments, through: :posts + # does not warn. Usage of the +comments+ association reports usage of + # +posts+, as we explained above, but the definition of the +has_many+ + # itself does not. + # + # * Similarly, accepts_nested_attributes_for :posts does not + # warn. Assignment to the posts attributes warns, as explained above, + # but the +accepts_nested_attributes_for+ call itself does not. + # + # * Same if an association declares to be inverse of a deprecated one, the + # macro itself does not warn. + # + # * In the same line, the declaration validates_associated :posts + # does not warn by itself, though access is reported when the validation + # runs. + # + # * Relation query methods like Author.includes(:posts) do not + # warn by themselves. At that point, that is a relation that internally + # stores a symbol for later use. As explained in the previous section, + # you get a warning when/if the query is executed. + # + # * Access to the reflection object of the association as in + # Author.reflect_on_association(:posts) or + # Author.reflect_on_all_associations does not warn. + # + # === Configuration + # + # Reporting deprecated usage can be configured: + # + # config.active_record.deprecated_associations_options = { ... } + # + # If present, this has to be a hash with keys +:mode+ and/or +:backtrace+. + # + # ==== Mode + # + # * In +:warn+ mode, usage issues a warning that includes the + # application-level place where the access happened, if any. This is the + # default mode. + # + # * In +:raise+ mode, usage raises an + # ActiveRecord::DeprecatedAssociationError with a similar message and a + # clean backtrace in the exception object. + # + # * In +:notify+ mode, a deprecated_association.active_record + # Active Support notification is published. The event payload has the + # association reflection (+:reflection+), the application-level location + # (+:location+) where the access happened (a Thread::Backtrace::Location + # object, or +nil+), and a deprecation message (+:message+). + # + # ==== Backtrace + # + # If :backtrace is true, warnings include a clean backtrace in the message + # and notifications have a +:backtrace+ key in the payload with an array + # of clean Thread::Backtrace::Location objects. Exceptions always get a + # clean stack trace set. + # + # Clean backtraces are computed using the Active Record backtrace cleaner. + # In Rails applications, that is by the default the same as + # Rails.backtrace_cleaner. + # # == Type safety with ActiveRecord::AssociationTypeMismatch # # If you attempt to assign an object to an association that doesn't match the inferred @@ -1287,6 +1407,9 @@ module ClassMethods # Defines an {association callback}[rdoc-ref:Associations::ClassMethods@Association+callbacks] that gets triggered before an object is removed from the association collection. # [:after_remove] # Defines an {association callback}[rdoc-ref:Associations::ClassMethods@Association+callbacks] that gets triggered after an object is removed from the association collection. + # [+:deprecated+] + # If true, marks the association as deprecated. Usage of deprecated associations is reported. + # Please, check the class documentation above for details. # # Option examples: # has_many :comments, -> { order("posted_on") } @@ -1484,6 +1607,9 @@ def has_many(name, scope = nil, **options, &extension) # 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. + # [+:deprecated+] + # If true, marks the association as deprecated. Usage of deprecated associations is reported. + # Please, check the class documentation above for details. # # Option examples: # has_one :credit_card, dependent: :destroy # destroys the associated credit card @@ -1676,6 +1802,9 @@ def has_one(name, scope = nil, **options) # 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. + # [+:deprecated+] + # If true, marks the association as deprecated. Usage of deprecated associations is reported. + # Please, check the class documentation above for details. # # Option examples: # belongs_to :firm, foreign_key: "client_of" @@ -1865,6 +1994,9 @@ def belongs_to(name, scope = nil, **options) # :autosave to true. # [+:strict_loading+] # Enforces strict loading every time an associated record is loaded through this association. + # [+:deprecated+] + # If true, marks the association as deprecated. Usage of deprecated associations is reported. + # Please, check the class documentation above for details. # # Option examples: # has_and_belongs_to_many :projects diff --git a/activerecord/lib/active_record/associations/builder/association.rb b/activerecord/lib/active_record/associations/builder/association.rb index ea9c695f0a9dd..65a84bb531422 100644 --- a/activerecord/lib/active_record/associations/builder/association.rb +++ b/activerecord/lib/active_record/associations/builder/association.rb @@ -19,7 +19,7 @@ class << self self.extensions = [] VALID_OPTIONS = [ - :anonymous_class, :primary_key, :foreign_key, :dependent, :validate, :inverse_of, :strict_loading, :query_constraints + :anonymous_class, :primary_key, :foreign_key, :dependent, :validate, :inverse_of, :strict_loading, :query_constraints, :deprecated ].freeze # :nodoc: def self.build(model, name, scope, options, &block) @@ -102,7 +102,9 @@ def self.define_accessors(model, reflection) def self.define_readers(mixin, name) mixin.class_eval <<-CODE, __FILE__, __LINE__ + 1 def #{name} - association(:#{name}).reader + association = association(:#{name}) + deprecated_associations_api_guard(association, __method__) + association.reader end CODE end @@ -110,7 +112,9 @@ def #{name} def self.define_writers(mixin, name) mixin.class_eval <<-CODE, __FILE__, __LINE__ + 1 def #{name}=(value) - association(:#{name}).writer(value) + association = association(:#{name}) + deprecated_associations_api_guard(association, __method__) + association.writer(value) end CODE end @@ -138,8 +142,15 @@ def self.check_dependent_options(dependent, model) end def self.add_destroy_callbacks(model, reflection) - name = reflection.name - model.before_destroy(->(o) { o.association(name).handle_dependency }) + if reflection.deprecated? + # If :dependent is set, destroying the record has a side effect that + # would no longer happen if the association is removed. + model.before_destroy do + report_deprecated_association(reflection, context: ":dependent has a side effect here") + end + end + + model.before_destroy(->(o) { o.association(reflection.name).handle_dependency }) end def self.add_after_commit_jobs_callback(model, dependent) diff --git a/activerecord/lib/active_record/associations/builder/belongs_to.rb b/activerecord/lib/active_record/associations/builder/belongs_to.rb index 2fbe39910d3df..ce537a818241c 100644 --- a/activerecord/lib/active_record/associations/builder/belongs_to.rb +++ b/activerecord/lib/active_record/associations/builder/belongs_to.rb @@ -108,6 +108,14 @@ def self.add_default_callbacks(model, reflection) end def self.add_destroy_callbacks(model, reflection) + if reflection.deprecated? + # If :dependent is set, destroying the record has some side effect that + # would no longer happen if the association is removed. + model.before_destroy do + report_deprecated_association(reflection, context: ":dependent has a side effect here") + end + end + model.after_destroy lambda { |o| o.association(reflection.name).handle_dependency } end @@ -145,11 +153,15 @@ def self.define_validations(model, reflection) def self.define_change_tracking_methods(model, reflection) model.generated_association_methods.class_eval <<-CODE, __FILE__, __LINE__ + 1 def #{reflection.name}_changed? - association(:#{reflection.name}).target_changed? + association = association(:#{reflection.name}) + deprecated_associations_api_guard(association, __method__) + association.target_changed? end def #{reflection.name}_previously_changed? - association(:#{reflection.name}).target_previously_changed? + association = association(:#{reflection.name}) + deprecated_associations_api_guard(association, __method__) + association.target_previously_changed? end CODE end diff --git a/activerecord/lib/active_record/associations/builder/collection_association.rb b/activerecord/lib/active_record/associations/builder/collection_association.rb index 2133b4ec23282..a1a248600266d 100644 --- a/activerecord/lib/active_record/associations/builder/collection_association.rb +++ b/activerecord/lib/active_record/associations/builder/collection_association.rb @@ -60,7 +60,9 @@ def self.define_readers(mixin, name) mixin.class_eval <<-CODE, __FILE__, __LINE__ + 1 def #{name.to_s.singularize}_ids - association(:#{name}).ids_reader + association = association(:#{name}) + deprecated_associations_api_guard(association, __method__) + association.ids_reader end CODE end @@ -70,7 +72,9 @@ def self.define_writers(mixin, name) mixin.class_eval <<-CODE, __FILE__, __LINE__ + 1 def #{name.to_s.singularize}_ids=(ids) - association(:#{name}).ids_writer(ids) + association = association(:#{name}) + deprecated_associations_api_guard(association, __method__) + association.ids_writer(ids) end CODE end diff --git a/activerecord/lib/active_record/associations/builder/singular_association.rb b/activerecord/lib/active_record/associations/builder/singular_association.rb index b66ca141b2a24..e28cc2e5eb24d 100644 --- a/activerecord/lib/active_record/associations/builder/singular_association.rb +++ b/activerecord/lib/active_record/associations/builder/singular_association.rb @@ -17,11 +17,15 @@ def self.define_accessors(model, reflection) mixin.class_eval <<-CODE, __FILE__, __LINE__ + 1 def reload_#{name} - association(:#{name}).force_reload_reader + association = association(:#{name}) + deprecated_associations_api_guard(association, __method__) + association.force_reload_reader end def reset_#{name} - association(:#{name}).reset + association = association(:#{name}) + deprecated_associations_api_guard(association, __method__) + association.reset end CODE end @@ -30,19 +34,43 @@ def reset_#{name} def self.define_constructors(mixin, name) mixin.class_eval <<-CODE, __FILE__, __LINE__ + 1 def build_#{name}(*args, &block) - association(:#{name}).build(*args, &block) + association = association(:#{name}) + deprecated_associations_api_guard(association, __method__) + association.build(*args, &block) end def create_#{name}(*args, &block) - association(:#{name}).create(*args, &block) + association = association(:#{name}) + deprecated_associations_api_guard(association, __method__) + association.create(*args, &block) end def create_#{name}!(*args, &block) - association(:#{name}).create!(*args, &block) + association = association(:#{name}) + deprecated_associations_api_guard(association, __method__) + association.create!(*args, &block) end CODE end + def self.define_callbacks(model, reflection) + super + + # If the record is saved or destroyed and `:touch` is set, the parent + # record gets a timestamp updated. We want to know about it, because + # deleting the association would change that side-effect and perhaps there + # is code relying on it. + if reflection.deprecated? && reflection.options[:touch] + model.before_save do + report_deprecated_association(reflection, context: ":touch has a side effect here") + end + + model.before_destroy do + report_deprecated_association(reflection, context: ":touch has a side effect here") + end + end + end + private_class_method :valid_options, :define_accessors, :define_constructors end end diff --git a/activerecord/lib/active_record/associations/deprecation.rb b/activerecord/lib/active_record/associations/deprecation.rb new file mode 100644 index 0000000000000..fb118d16c56c6 --- /dev/null +++ b/activerecord/lib/active_record/associations/deprecation.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +require "active_support/notifications" +require "active_support/core_ext/array/conversions" + +module ActiveRecord::Associations::Deprecation # :nodoc: + EVENT = "deprecated_association.active_record" + private_constant :EVENT + + MODES = [:warn, :raise, :notify].freeze + private_constant :MODES + + class << self + attr_reader :mode, :backtrace + + def mode=(value) # private setter + unless MODES.include?(value) + raise ArgumentError, "invalid deprecated associations mode #{value.inspect} (valid modes are #{MODES.map(&:inspect).to_sentence})" + end + + @mode = value + end + + def backtrace=(value) + @backtrace = !!value + end + + def guard(reflection) + report(reflection, context: yield) if reflection.deprecated? + + if reflection.through_reflection? + reflection.deprecated_nested_reflections.each do |deprecated_nested_reflection| + context = "referenced as nested association of the through #{reflection.active_record}##{reflection.name}" + report(deprecated_nested_reflection, context: context) + end + end + end + + def report(reflection, context:) + message = +"The association #{reflection.active_record}##{reflection.name} is deprecated, #{context}" + message << " (#{backtrace_cleaner.first_clean_frame})" + + case @mode + when :warn + message = [message, *clean_frames].join("\n\t") if @backtrace + ActiveRecord::Base.logger&.warn(message) + when :raise + error = ActiveRecord::DeprecatedAssociationError.new(message) + if set_backtrace_supports_array_of_locations? + error.set_backtrace(clean_locations) + else + error.set_backtrace(clean_frames) + end + raise error + else + payload = { reflection: reflection, message: message, location: backtrace_cleaner.first_clean_location } + payload[:backtrace] = clean_locations if @backtrace + ActiveSupport::Notifications.instrument(EVENT, payload) + end + end + + private + def backtrace_cleaner + ActiveRecord::LogSubscriber.backtrace_cleaner + end + + def clean_frames + backtrace_cleaner.clean(caller) + end + + def clean_locations + backtrace_cleaner.clean_locations(caller_locations) + end + + def set_backtrace_supports_array_of_locations? + @backtrace_supports_array_of_locations ||= Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("3.4.0") + end + end + + self.mode = :warn + self.backtrace = false +end diff --git a/activerecord/lib/active_record/associations/errors.rb b/activerecord/lib/active_record/associations/errors.rb index 8bb0bd2ad3db2..66f57aada003f 100644 --- a/activerecord/lib/active_record/associations/errors.rb +++ b/activerecord/lib/active_record/associations/errors.rb @@ -262,4 +262,7 @@ def initialize(name = nil) end end end + + class DeprecatedAssociationError < ActiveRecordError + end end diff --git a/activerecord/lib/active_record/associations/join_dependency.rb b/activerecord/lib/active_record/associations/join_dependency.rb index 975f2035afa9c..1147b42287ceb 100644 --- a/activerecord/lib/active_record/associations/join_dependency.rb +++ b/activerecord/lib/active_record/associations/join_dependency.rb @@ -235,6 +235,8 @@ def build(associations, base_klass) raise EagerLoadPolymorphicError.new(reflection) end + Deprecation.guard(reflection) { "referenced in query to join its table" } + JoinAssociation.new(reflection, build(right, reflection.klass)) end end diff --git a/activerecord/lib/active_record/associations/preloader/branch.rb b/activerecord/lib/active_record/associations/preloader/branch.rb index 13bac93dfde9f..eabdc2c36f070 100644 --- a/activerecord/lib/active_record/associations/preloader/branch.rb +++ b/activerecord/lib/active_record/associations/preloader/branch.rb @@ -118,6 +118,7 @@ def polymorphic? def loaders @loaders ||= grouped_records.flat_map do |reflection, reflection_records| + Deprecation.guard(reflection) { "referenced in query to preload records" } preloaders_for_reflection(reflection, reflection_records) end end diff --git a/activerecord/lib/active_record/errors.rb b/activerecord/lib/active_record/errors.rb index 09cb1d3a23039..876da2a6538f6 100644 --- a/activerecord/lib/active_record/errors.rb +++ b/activerecord/lib/active_record/errors.rb @@ -609,6 +609,9 @@ class UnknownAttributeReference < ActiveRecordError # the database version cannot be determined. class DatabaseVersionError < ActiveRecordError end + + class DeprecatedAssociationError < ActiveRecordError + end end require "active_record/associations/errors" diff --git a/activerecord/lib/active_record/nested_attributes.rb b/activerecord/lib/active_record/nested_attributes.rb index 0e2dc1e75edf3..c20a1022a3e36 100644 --- a/activerecord/lib/active_record/nested_attributes.rb +++ b/activerecord/lib/active_record/nested_attributes.rb @@ -387,6 +387,8 @@ def generate_association_writer(association_name, type) generated_association_methods.module_eval <<-eoruby, __FILE__, __LINE__ + 1 silence_redefinition_of_method :#{association_name}_attributes= def #{association_name}_attributes=(attributes) + association = association(:#{association_name}) + deprecated_associations_api_guard(association, __method__) assign_nested_attributes_for_#{type}_association(:#{association_name}, attributes) end eoruby diff --git a/activerecord/lib/active_record/railtie.rb b/activerecord/lib/active_record/railtie.rb index 582c4d9840768..c680a70110356 100644 --- a/activerecord/lib/active_record/railtie.rb +++ b/activerecord/lib/active_record/railtie.rb @@ -40,6 +40,7 @@ class Railtie < Rails::Railtie # :nodoc: config.active_record.belongs_to_required_validates_foreign_key = true config.active_record.generate_secure_token_on = :create config.active_record.use_legacy_signed_id_verifier = :generate_and_verify + config.active_record.deprecated_associations_options = { mode: :warn, backtrace: false } config.active_record.queues = ActiveSupport::InheritableOptions.new diff --git a/activerecord/lib/active_record/reflection.rb b/activerecord/lib/active_record/reflection.rb index 79bd4c2702afa..1c61b6ff98306 100644 --- a/activerecord/lib/active_record/reflection.rb +++ b/activerecord/lib/active_record/reflection.rb @@ -540,6 +540,8 @@ def initialize(name, scope, options, active_record) options[:query_constraints] = options.delete(:foreign_key) end + @deprecated = !!options[:deprecated] + ensure_option_not_given_as_class!(:class_name) end @@ -752,6 +754,10 @@ def extensions Array(options[:extend]) end + def deprecated? + @deprecated + end + private # Attempts to find the inverse association name automatically. # If it cannot find a suitable inverse association name, it returns @@ -1211,11 +1217,41 @@ def add_as_through(seed) collect_join_reflections(seed + [self]) end + def deprecated? + unless defined?(@deprecated) + @deprecated = + if parent_reflection.is_a?(HasAndBelongsToManyReflection) + parent_reflection.deprecated? + else + delegate_reflection.deprecated? + end + end + + @deprecated + end + + def deprecated_nested_reflections + @deprecated_nested_reflections ||= collect_deprecated_nested_reflections + end + protected def actual_source_reflection # FIXME: this is a horrible name source_reflection.actual_source_reflection end + def collect_deprecated_nested_reflections + result = [] + [through_reflection, source_reflection].each do |reflection| + result << reflection if reflection.deprecated? + # Both the through and the source reflections could be through + # themselves. Nesting can go an arbitrary number of levels down. + if reflection.through_reflection? + result.concat(reflection.deprecated_nested_reflections) + end + end + result + end + private attr_reader :delegate_reflection diff --git a/activerecord/test/cases/associations/belongs_to_associations_test.rb b/activerecord/test/cases/associations/belongs_to_associations_test.rb index 132add71c8350..0fef372b5042a 100644 --- a/activerecord/test/cases/associations/belongs_to_associations_test.rb +++ b/activerecord/test/cases/associations/belongs_to_associations_test.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "cases/helper" +require "support/deprecated_associations_test_helpers" require "models/developer" require "models/project" require "models/company" @@ -31,9 +32,12 @@ require "models/node" require "models/club" require "models/cpk" +require "models/person" # not used by this suite as of this writing, it is a workaround for https://github.com/rails/rails/issues/55133 +require "models/car" require "models/sharded/blog" require "models/sharded/blog_post" require "models/sharded/comment" +require "models/dats" class BelongsToAssociationsTest < ActiveRecord::TestCase fixtures :accounts, :companies, :developers, :projects, :topics, @@ -1888,3 +1892,171 @@ def test_async_load_belongs_to end end end + +class DeprecatedBelongsToAssociationsTest < ActiveRecord::TestCase + include DeprecatedAssociationsTestHelpers + + fixtures :cars + + def modify_car_name_directly_in_the_database(car) + new_name = "#{car.name} edited" + DATS::Car.connection.execute("UPDATE cars SET name = '#{new_name}'") + new_name + end + + setup do + @model = DATS::Bulb + @car = DATS::Car.first + @bulb = @car.create_bulb!(name: "for belongs_to deprecated test suite") + end + + test "" do + assert_not_deprecated_association(:car) do + @bulb.car + end + + assert_deprecated_association(:deprecated_car, context: context_for_method(:deprecated_car)) do + assert_equal @car, @bulb.deprecated_car + end + end + + test "=" do + car = DATS::Car.new + + assert_not_deprecated_association(:car) do + @bulb.car = car + end + + assert_deprecated_association(:deprecated_car, context: context_for_method(:deprecated_car=)) do + @bulb.deprecated_car = car + end + assert_same car, @bulb.deprecated_car + end + + test "reload_" do + assert_not_deprecated_association(:car) do + @bulb.reload_car + end + + deprecated_car = @bulb.deprecated_car # caches the associated object + new_name = modify_car_name_directly_in_the_database(deprecated_car) + assert_deprecated_association(:deprecated_car, context: context_for_method(:reload_deprecated_car)) do + assert_equal new_name, @bulb.reload_deprecated_car.name + end + end + + test "reset_" do + assert_not_deprecated_association(:car) do + @bulb.reset_car + end + + deprecated_car = @bulb.deprecated_car # caches the associated object + new_name = modify_car_name_directly_in_the_database(deprecated_car) + + assert_deprecated_association(:deprecated_car, context: context_for_method(:reset_deprecated_car)) do + @bulb.reset_deprecated_car + end + + assert_equal new_name, @bulb.deprecated_car.name + end + + test "build_" do + assert_not_deprecated_association(:car) do + @bulb.build_car + end + + assert_deprecated_association(:deprecated_car, context: context_for_method(:build_deprecated_car)) do + assert_instance_of DATS::Car, @bulb.build_deprecated_car + end + end + + test "create_" do + assert_not_deprecated_association(:car) do + @bulb.create_car + end + + assert_deprecated_association(:deprecated_car, context: context_for_method(:create_deprecated_car)) do + assert_predicate @bulb.create_deprecated_car, :persisted? + end + end + + test "create_!" do + assert_not_deprecated_association(:car) do + @bulb.create_car! + end + + assert_deprecated_association(:deprecated_car, context: context_for_method(:create_deprecated_car!)) do + assert_predicate @bulb.create_deprecated_car!, :persisted? + end + end + + test "_changed?" do + assert_not_deprecated_association(:car) do + @bulb.car_changed? + end + + assert_deprecated_association(:deprecated_car, context: context_for_method(:deprecated_car_changed?)) do + assert_not_predicate @bulb, :deprecated_car_changed? + end + end + + test "_previously_changed?" do + assert_not_deprecated_association(:car) do + @bulb.car_previously_changed? + end + + assert_deprecated_association(:deprecated_car, context: context_for_method(:deprecated_car_previously_changed?)) do + assert_predicate @bulb, :deprecated_car_previously_changed? + end + end + + test "parent destroy (not deprecated)" do + assert_not_deprecated_association(:car) do + @bulb.destroy + end + assert_predicate @car, :destroyed? + end + + test "parent destroy (deprecated)" do + assert_deprecated_association(:deprecated_car, context: context_for_dependent) do + @bulb.destroy + end + assert_predicate @car, :destroyed? + end + + test "touch notifies on creation (not deprecated)" do + assert_not_deprecated_association(:car) do + @car.create_bulb!(name: "for belongs_to deprecated test suite") + end + end + + test "touch notifies on creation (deprecated)" do + assert_deprecated_association(:deprecated_car, context: context_for_touch) do + @car.create_bulb!(name: "for belongs_to deprecated test suite") + end + end + + test "touch notifies on update" do + assert_not_deprecated_association(:car) do + @bulb.update(name: "#{@bulb.name} edited") + end + + assert_deprecated_association(:deprecated_car, context: context_for_touch) do + assert @bulb.update(name: "#{@bulb.name} again") + end + end + + test "touch notifies on destroy (not deprecated)" do + assert_not_deprecated_association(:car) do + @bulb.destroy + end + assert_predicate @car, :destroyed? + end + + test "touch notifies on destroy (deprecated)" do + assert_deprecated_association(:deprecated_car, context: context_for_touch) do + @bulb.destroy + end + assert_predicate @car, :destroyed? + end +end diff --git a/activerecord/test/cases/associations/deprecation_test.rb b/activerecord/test/cases/associations/deprecation_test.rb new file mode 100644 index 0000000000000..6629e1ddef542 --- /dev/null +++ b/activerecord/test/cases/associations/deprecation_test.rb @@ -0,0 +1,238 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/dats" + +# The logic of the `guard` method is extensively tested indirectly, via the test +# suites of each type of association. +# +# Those tests verify that the `report` method is invoked as expected. Here, we +# unit test the `report` method itself. +module AssociationDeprecationTest + class TestCase < ActiveRecord::TestCase + # __FILE__ is relative if passed as an argument to ruby, and it is absolute + # when running the suite. We'll take the path relative to the component to + # make backtraces generated by the backtrace cleaner to be more predictable + # and easy to test. + THIS_FILE = __FILE__.sub(%r(.*?/activerecord/(?=test/)), "") + + def setup + @original_mode = ActiveRecord::Associations::Deprecation.mode + @original_backtrace = ActiveRecord::Associations::Deprecation.backtrace + @original_backtrace_cleaner = ActiveRecord::LogSubscriber.backtrace_cleaner + + bc = ActiveSupport::BacktraceCleaner.new + bc.add_silencer { !_1.include?(THIS_FILE) } + + ActiveRecord::LogSubscriber.backtrace_cleaner = bc + end + + def teardown + ActiveRecord::Associations::Deprecation.mode = @original_mode + ActiveRecord::Associations::Deprecation.backtrace = @original_backtrace + ActiveRecord::LogSubscriber.backtrace_cleaner = @original_backtrace_cleaner + end + + def assert_message(message, line) + re = /The association DATS::Car#deprecated_tyres is deprecated, the method deprecated_tyres was invoked \(#{__FILE__}:#{line}:in/ + assert_match re, message + end + end + + class OptionsTest < TestCase + test "valid options, mode only" do + ActiveRecord.deprecated_associations_options = { mode: :warn } + assert_equal :warn, ActiveRecord::Associations::Deprecation.mode + assert_equal false, ActiveRecord::Associations::Deprecation.backtrace + end + + test "valid options, backtrace only" do + ActiveRecord.deprecated_associations_options = { backtrace: true } + assert_equal :warn, ActiveRecord::Associations::Deprecation.mode + assert_equal true, ActiveRecord::Associations::Deprecation.backtrace + end + + test "valid options, both" do + ActiveRecord.deprecated_associations_options = { + mode: :notify, + backtrace: true + } + assert_equal :notify, ActiveRecord::Associations::Deprecation.mode + assert_equal true, ActiveRecord::Associations::Deprecation.backtrace + end + + test "not a hash" do + error = assert_raises(ArgumentError) do + ActiveRecord.deprecated_associations_options = :invalid + end + assert_equal "deprecated_associations_options must be a hash", error.message + end + + test "invalid keys" do + error = assert_raises(ArgumentError) do + ActiveRecord.deprecated_associations_options = { invalid: true } + end + assert_equal "invalid deprecated_associations_options key :invalid (valid keys are :mode and :backtrace)", error.message + end + + test "invalid mode" do + error = assert_raises(ArgumentError) do + ActiveRecord.deprecated_associations_options = { mode: :invalid } + end + assert_equal "invalid deprecated associations mode :invalid (valid modes are :warn, :raise, and :notify)", error.message + end + end + + class ModeWriterTest < TestCase + test "valid values" do + [:warn, :raise, :notify].each do |mode| + ActiveRecord::Associations::Deprecation.mode = mode + assert_equal mode, ActiveRecord::Associations::Deprecation.mode + end + end + + test "invalid values" do + error = assert_raises(ArgumentError) do + ActiveRecord::Associations::Deprecation.mode = :invalid + end + assert_equal "invalid deprecated associations mode :invalid (valid modes are :warn, :raise, and :notify)", error.message + end + + test "the backtrace flag becomes a true/false singleton" do + ActiveRecord::Associations::Deprecation.backtrace = 1 + assert_same true, ActiveRecord::Associations::Deprecation.backtrace + + ActiveRecord::Associations::Deprecation.backtrace = nil + assert_same false, ActiveRecord::Associations::Deprecation.backtrace + end + end + + class WarnModeTest < TestCase + def setup + super + ActiveRecord::Associations::Deprecation.mode = :warn + + @original_logger = ActiveRecord::Base.logger + @io = StringIO.new + ActiveRecord::Base.logger = Logger.new(@io) + end + + def teardown + super + ActiveRecord::Base.logger = @original_logger + end + + test "report warns in :warn mode" do + DATS::Car.new.deprecated_tyres + assert_message @io.string, __LINE__ - 1 + end + end + + class WarnBacktraceModeTest < TestCase + def setup + super + ActiveRecord::Associations::Deprecation.mode = :warn + ActiveRecord::Associations::Deprecation.backtrace = true + + @original_logger = ActiveRecord::Base.logger + @io = StringIO.new + ActiveRecord::Base.logger = Logger.new(@io) + end + + def teardown + super + ActiveRecord::Base.logger = @original_logger + end + + test "report warns in :warn mode" do + line = __LINE__ + 1 + DATS::Car.new.deprecated_tyres + + assert_message @io.string, line + assert_includes @io.string, "\t#{__FILE__}:#{line}:in" + end + end + + class WarnModeNoLoggerTest < TestCase + def setup + super + ActiveRecord::Associations::Deprecation.mode = :warn + + @original_logger = ActiveRecord::Base.logger + ActiveRecord::Base.logger = nil + end + + def teardown + super + ActiveRecord::Base.logger = @original_logger + end + + test "report does not assume the logger is present" do + assert_nothing_raised { DATS::Car.new.deprecated_tyres } + end + end + + class RaiseModeTest < TestCase + def setup + super + ActiveRecord::Associations::Deprecation.mode = :raise + end + + test "report raises an error in :raise mode" do + error = assert_raises(ActiveRecord::DeprecatedAssociationError) { DATS::Car.new.deprecated_tyres } + assert_message error.message, __LINE__ - 1 + end + end + + class RaiseBacktraceModeTest < TestCase + def setup + super + ActiveRecord::Associations::Deprecation.mode = :raise + ActiveRecord::Associations::Deprecation.backtrace = true + end + + test "report raises an error in :raise mode" do + line = __LINE__ + 1 + error = assert_raises(ActiveRecord::DeprecatedAssociationError) { DATS::Car.new.deprecated_tyres } + + assert_message error.message, line + assert_includes error.backtrace.last, "#{__FILE__}:#{line}:in" + end + end + + class NotifyModeTest < TestCase + def setup + super + ActiveRecord::Associations::Deprecation.mode = :notify + ActiveRecord::Associations::Deprecation.backtrace = true + end + + def teardown + super + ActiveRecord::LogSubscriber.backtrace_cleaner = @original_backtrace_cleaner + end + + test "report publishes an Active Support notification in :notify mode" do + payloads = [] + callback = ->(event) { payloads << event.payload } + + line = __LINE__ + 2 + ActiveSupport::Notifications.subscribed(callback, "deprecated_association.active_record") do + DATS::Car.new.deprecated_tyres + end + + assert_equal 1, payloads.size + payload = payloads.first + + assert_equal DATS::Car.reflect_on_association(:deprecated_tyres), payload[:reflection] + + assert_message payload[:message], line + + assert_equal __FILE__, payload[:location].path + assert_equal line, payload[:location].lineno + + assert_equal __FILE__, payload[:backtrace][-2].path + assert_equal line, payload[:backtrace][-2].lineno + end + end +end 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 93ce858884f92..10f1893ea8808 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 @@ -1,6 +1,7 @@ # frozen_string_literal: true require "cases/helper" +require "support/deprecated_associations_test_helpers" require "models/developer" require "models/computer" require "models/project" @@ -33,6 +34,7 @@ require "models/publisher" require "models/publisher/article" require "models/publisher/magazine" +require "models/dats" require "active_support/core_ext/string/conversions" class ProjectWithAfterCreateHook < ActiveRecord::Base @@ -995,3 +997,60 @@ def test_has_and_belongs_to_many_with_belongs_to assert_equal 1, sink.sources.count end end + +class DeprecatedHasAndBelongsToManyAssociationsTest < ActiveRecord::TestCase + include DeprecatedAssociationsTestHelpers + + fixtures :categories + + setup do + @model = DATS::Category + @category = @model.first + end + + test "" do + assert_not_deprecated_association(:posts) do + @category.posts + end + + assert_deprecated_association(:deprecated_posts, context: context_for_method(:deprecated_posts)) do + assert_equal @category.posts, @category.deprecated_posts + end + end + + test "=" do + post = DATS::Post.new(title: "Title", body: "Body") + + assert_not_deprecated_association(:posts) do + @category.posts = [post] + end + + assert_deprecated_association(:deprecated_posts, context: context_for_method(:deprecated_posts=)) do + @category.deprecated_posts = [post] + end + assert_equal [post], @category.deprecated_posts + end + + test "_ids" do + assert_not_deprecated_association(:posts) do + @category.post_ids + end + + assert_deprecated_association(:deprecated_posts, context: context_for_method(:deprecated_post_ids)) do + assert_equal @category.post_ids, @category.deprecated_post_ids + end + end + + test "_ids=" do + post = DATS::Post.create!(title: "Title", body: "Body") + + assert_not_deprecated_association(:posts) do + @category.post_ids = [post.id] + end + + assert_deprecated_association(:deprecated_posts, context: context_for_method(:deprecated_post_ids=)) do + @category.deprecated_post_ids = [post.id] + end + assert_equal [post.id], @category.deprecated_post_ids + end +end diff --git a/activerecord/test/cases/associations/has_many_associations_test.rb b/activerecord/test/cases/associations/has_many_associations_test.rb index f32f408f6b3e4..9aa0cb4046484 100644 --- a/activerecord/test/cases/associations/has_many_associations_test.rb +++ b/activerecord/test/cases/associations/has_many_associations_test.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "cases/helper" +require "support/deprecated_associations_test_helpers" require "models/developer" require "models/computer" require "models/project" @@ -44,6 +45,7 @@ require "models/sharded" require "models/cpk" require "models/comment_overlapping_counter_cache" +require "models/dats" class HasManyAssociationsTestForReorderWithJoinDependency < ActiveRecord::TestCase fixtures :authors, :author_addresses, :posts, :comments @@ -3199,7 +3201,7 @@ def test_invalid_key_raises_with_message_including_all_default_options assert_equal(<<~MESSAGE.squish, error.message) Unknown key: :trough. Valid keys are: :anonymous_class, :primary_key, :foreign_key, :dependent, :validate, :inverse_of, - :strict_loading, :query_constraints, :autosave, :class_name, :before_add, + :strict_loading, :query_constraints, :deprecated, :autosave, :class_name, :before_add, :after_add, :before_remove, :after_remove, :extend, :counter_cache, :join_table, :index_errors, :as, :through MESSAGE @@ -3283,3 +3285,74 @@ def test_async_load_has_many end end end + +class DeprecatedHasManyAssociationsTest < ActiveRecord::TestCase + include DeprecatedAssociationsTestHelpers + + fixtures :cars + + setup do + @model = DATS::Car + @car = @model.first + end + + test "" do + assert_not_deprecated_association(:tyres) do + @car.tyres + end + + assert_deprecated_association(:deprecated_tyres, context: context_for_method(:deprecated_tyres)) do + assert_equal @car.tyres, @car.deprecated_tyres + end + end + + test "=" do + tyre = DATS::Tyre.new + + assert_not_deprecated_association(:tyres) do + @car.tyres = [tyre] + end + + assert_deprecated_association(:deprecated_tyres, context: context_for_method(:deprecated_tyres=)) do + @car.deprecated_tyres = [tyre] + end + assert_equal [tyre], @car.deprecated_tyres + end + + test "_ids" do + assert_not_deprecated_association(:tyres) do + @car.tyre_ids + end + + assert_deprecated_association(:deprecated_tyres, context: context_for_method(:deprecated_tyre_ids)) do + assert_equal @car.tyre_ids, @car.deprecated_tyre_ids + end + end + + test "_ids=" do + tyre = @car.tyres.create! + + assert_not_deprecated_association(:tyres) do + @car.tyre_ids = [tyre.id] + end + + assert_deprecated_association(:deprecated_tyres, context: context_for_method(:deprecated_tyre_ids=)) do + @car.deprecated_tyre_ids = [tyre.id] + end + assert_equal [tyre.id], @car.deprecated_tyre_ids + end + + test "destroy (not deprecated)" do + assert_not_deprecated_association(:tyres) do + @car.destroy + end + assert_predicate @car, :destroyed? + end + + test "destroy (deprecated)" do + assert_deprecated_association(:deprecated_tyres, context: context_for_dependent) do + @car.destroy + end + assert_predicate @car, :destroyed? + end +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 f3b8764259324..331fccc7d745c 100644 --- a/activerecord/test/cases/associations/has_many_through_associations_test.rb +++ b/activerecord/test/cases/associations/has_many_through_associations_test.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "cases/helper" +require "support/deprecated_associations_test_helpers" require "models/post" require "models/person" require "models/reference" @@ -44,6 +45,7 @@ require "models/zine" require "models/interest" require "models/human" +require "models/dats" class HasManyThroughAssociationsTest < ActiveRecord::TestCase fixtures :posts, :readers, :people, :comments, :authors, :categories, :taggings, :tags, @@ -1722,3 +1724,56 @@ def make_no_pk_hm_t [lesson, lesson_student, student] end end + +class DeprecatedHasManyThroughAssociationsTest < ActiveRecord::TestCase + include DeprecatedAssociationsTestHelpers + + fixtures :authors + + setup do + @model = DATS::Author + @author = @model.first + end + + test "the has_many itself is deprecated" do + assert_not_deprecated_association(:comments) do + @author.comments + end + + assert_deprecated_association(:deprecated_has_many_through, context: context_for_method(:deprecated_has_many_through)) do + @author.deprecated_has_many_through + end + end + + test "the through association is deprecated" do + assert_deprecated_association(:deprecated_posts, context: context_for_through(:deprecated_through)) do + @author.deprecated_through + end + end + + test "the source association is deprecated" do + assert_deprecated_association(:deprecated_comments, model: DATS::Post, context: context_for_through(:deprecated_source)) do + @author.deprecated_source + end + end + + test "all deprecated" do + assert_deprecated_association(:deprecated_all, context: context_for_method(:deprecated_all)) do + @author.deprecated_all + end + + assert_deprecated_association(:deprecated_posts, context: context_for_through(:deprecated_all)) do + @author.deprecated_all + end + + assert_deprecated_association(:deprecated_comments, model: DATS::Post, context: context_for_through(:deprecated_all)) do + @author.deprecated_all + end + end + + test "deprecated nested association" do + assert_deprecated_association(:deprecated_author_favorites, context: context_for_through(:deprecated_nested)) do + @author.deprecated_nested.uniq + end + end +end diff --git a/activerecord/test/cases/associations/has_one_associations_test.rb b/activerecord/test/cases/associations/has_one_associations_test.rb index 0da12625c05d0..a5fd34c2fe158 100644 --- a/activerecord/test/cases/associations/has_one_associations_test.rb +++ b/activerecord/test/cases/associations/has_one_associations_test.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "cases/helper" +require "support/deprecated_associations_test_helpers" require "models/developer" require "models/computer" require "models/project" @@ -22,6 +23,7 @@ require "models/cpk" require "models/room" require "models/user" +require "models/dats" class HasOneAssociationsTest < ActiveRecord::TestCase self.use_transactional_tests = false unless supports_savepoints? @@ -1001,3 +1003,131 @@ def test_async_load_has_one end end end + +class DeprecatedHasOneAssociationsTest < ActiveRecord::TestCase + include DeprecatedAssociationsTestHelpers + + fixtures :cars + + def create_bulb! + @bulb = @car.create_bulb!(name: "for has_one deprecated test suite") + end + + def modify_bulb_name_directly_in_the_database(bulb) + new_name = "#{bulb.name} edited" + DATS::Bulb.connection.execute("UPDATE bulbs SET name = '#{new_name}'") + new_name + end + + setup do + @model = DATS::Car + @car = @model.first + end + + test "" do + create_bulb! + + assert_not_deprecated_association(:bulb) do + @car.bulb + end + + assert_deprecated_association(:deprecated_bulb, context: context_for_method(:deprecated_bulb)) do + assert_equal @bulb, @car.deprecated_bulb + end + end + + test "=" do + bulb = DATS::Bulb.new + + assert_not_deprecated_association(:bulb) do + @car.bulb = bulb + end + + assert_deprecated_association(:deprecated_bulb, context: context_for_method(:deprecated_bulb=)) do + @car.deprecated_bulb = bulb + end + assert_same bulb, @car.deprecated_bulb + end + + test "reload_" do + create_bulb! + + assert_not_deprecated_association(:bulb) do + @car.reload_bulb + end + + deprecated_bulb = @car.deprecated_bulb # caches the associated object + new_name = modify_bulb_name_directly_in_the_database(deprecated_bulb) + + assert_deprecated_association(:deprecated_bulb, context: context_for_method(:reload_deprecated_bulb)) do + assert_equal new_name, @car.reload_deprecated_bulb.name + end + end + + test "reset_" do + create_bulb! + + assert_not_deprecated_association(:bulb) do + @car.reset_bulb + end + + deprecated_bulb = @car.deprecated_bulb # caches the associated object + new_name = modify_bulb_name_directly_in_the_database(deprecated_bulb) + + assert_deprecated_association(:deprecated_bulb, context: context_for_method(:reset_deprecated_bulb)) do + @car.reset_deprecated_bulb + end + assert_equal new_name, @car.deprecated_bulb.name + end + + test "build_" do + assert_not_deprecated_association(:bulb) do + @car.build_bulb + end + + assert_deprecated_association(:deprecated_bulb, context: context_for_method(:build_deprecated_bulb)) do + assert_instance_of DATS::Bulb, @car.build_deprecated_bulb + end + end + + test "create_" do + assert_not_deprecated_association(:bulb) do + @car.create_bulb + end + + assert_deprecated_association(:deprecated_bulb, context: context_for_method(:create_deprecated_bulb)) do + assert_predicate @car.create_deprecated_bulb, :persisted? + end + end + + test "create_!" do + assert_not_deprecated_association(:bulb) do + @car.create_bulb! + end + + assert_deprecated_association(:deprecated_bulb, context: context_for_method(:create_deprecated_bulb!)) do + @car.create_deprecated_bulb! + end + assert_predicate @car.deprecated_bulb, :persisted? + end + + test "parent destroy (not deprecated)" do + create_bulb! + + assert_not_deprecated_association(:bulb) do + @car.destroy + end + assert_predicate @car, :destroyed? + end + + test "parent destroy (deprecated)" do + create_bulb! + + deprecated_bulb = @car.deprecated_bulb + assert_deprecated_association(:deprecated_bulb, context: context_for_dependent) do + @car.destroy + end + assert_predicate @car, :destroyed? + assert_predicate deprecated_bulb, :destroyed? + end +end diff --git a/activerecord/test/cases/associations/has_one_through_associations_test.rb b/activerecord/test/cases/associations/has_one_through_associations_test.rb index e674bcd5a87f5..7ff4c10e2668e 100644 --- a/activerecord/test/cases/associations/has_one_through_associations_test.rb +++ b/activerecord/test/cases/associations/has_one_through_associations_test.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "cases/helper" +require "support/deprecated_associations_test_helpers" require "models/club" require "models/member_type" require "models/member" @@ -23,6 +24,7 @@ require "models/shop_account" require "models/customer_carrier" require "models/cpk" +require "models/dats" class HasOneThroughAssociationsTest < ActiveRecord::TestCase fixtures :member_types, :members, :clubs, :memberships, :sponsors, :organizations, :minivans, @@ -476,3 +478,56 @@ def test_cpk_stale_target assert_predicate(book.association(:order_agreement), :stale_target?) end end + +class DeprecatedHasOneThroughAssociationsTest < ActiveRecord::TestCase + include DeprecatedAssociationsTestHelpers + + fixtures :authors + + setup do + @model = DATS::Author + @author = @model.new + end + + test "the has_one itself is deprecated" do + assert_not_deprecated_association(:comment) do + @author.comment + end + + assert_deprecated_association(:deprecated_has_one_through, context: context_for_method(:deprecated_has_one_through)) do + @author.deprecated_has_one_through + end + end + + test "the through association is deprecated" do + assert_deprecated_association(:deprecated_post, context: context_for_through(:deprecated_through1)) do + @author.deprecated_through1 + end + end + + test "the source association is deprecated" do + assert_deprecated_association(:deprecated_comment, model: DATS::Post, context: context_for_through(:deprecated_source1)) do + @author.deprecated_source1 + end + end + + test "all deprecated" do + assert_deprecated_association(:deprecated_all1, context: context_for_method(:deprecated_all1)) do + @author.deprecated_all1 + end + + assert_deprecated_association(:deprecated_post, context: context_for_through(:deprecated_all1)) do + @author.deprecated_all1 + end + + assert_deprecated_association(:deprecated_comment, model: DATS::Post, context: context_for_through(:deprecated_all1)) do + @author.deprecated_all1 + end + end + + test "deprecated nested association" do + assert_deprecated_association(:deprecated_author_favorite, context: context_for_through(:deprecated_nested1)) do + @author.deprecated_nested1 + end + end +end diff --git a/activerecord/test/cases/errors_test.rb b/activerecord/test/cases/errors_test.rb index 4f701a20856e9..23c15b76aba90 100644 --- a/activerecord/test/cases/errors_test.rb +++ b/activerecord/test/cases/errors_test.rb @@ -8,8 +8,12 @@ def test_can_be_instantiated_with_no_args base = ActiveRecord::ActiveRecordError error_klasses = ObjectSpace.each_object(Class).select { |klass| klass < base } + expected_to_be_initializable_with_no_args = error_klasses - [ + ActiveRecord::AmbiguousSourceReflectionForThroughAssociation, + ActiveRecord::DeprecatedAssociationError + ] assert_nothing_raised do - (error_klasses - [ActiveRecord::AmbiguousSourceReflectionForThroughAssociation]).each do |error_klass| + expected_to_be_initializable_with_no_args.each do |error_klass| error_klass.new.inspect rescue ArgumentError raise "Instance of #{error_klass} can't be initialized with no arguments" diff --git a/activerecord/test/cases/nested_attributes_test.rb b/activerecord/test/cases/nested_attributes_test.rb index 775ae821064b3..07fc968bc24ee 100644 --- a/activerecord/test/cases/nested_attributes_test.rb +++ b/activerecord/test/cases/nested_attributes_test.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "cases/helper" +require "support/deprecated_associations_test_helpers" require "models/pirate" require "models/developer" require "models/ship" @@ -15,6 +16,8 @@ require "models/entry" require "models/message" require "models/cpk" +require "models/car" +require "models/dats" require "active_support/hash_with_indifferent_access" class TestNestedAttributesInGeneral < ActiveRecord::TestCase @@ -1235,3 +1238,40 @@ def test_should_build_a_new_record_based_on_the_delegated_type assert_equal "Hello world!", @entry.entryable.subject end end + +class NestedAttributesForDeprecatedAssociationsTest < ActiveRecord::TestCase + include DeprecatedAssociationsTestHelpers + + fixtures :cars + + setup do + @model = DATS::Car + @car = @model.first + @tyre_attributes = {} + @bulb_attributes = { "name" => "name for deprecated nested attributes" } + end + + test "has_many" do + assert_not_deprecated_association(:tyres) do + @car.tyres_attributes = [@tyre_attributes] + end + + assert_deprecated_association(:deprecated_tyres, context: context_for_method(:deprecated_tyres_attributes=)) do + @car.deprecated_tyres_attributes = [@tyre_attributes] + end + + assert @tyre_attributes <= @car.deprecated_tyres[0].attributes + end + + test "has_one" do + assert_not_deprecated_association(:bulb) do + @car.bulb_attributes = @bulb_attributes + end + + assert_deprecated_association(:deprecated_bulb, context: context_for_method(:deprecated_bulb_attributes=)) do + @car.deprecated_bulb_attributes = @bulb_attributes + end + + assert @bulb_attributes <= @car.deprecated_bulb.attributes + end +end diff --git a/activerecord/test/cases/reflection_test.rb b/activerecord/test/cases/reflection_test.rb index 8a6183832780a..c926625126c8b 100644 --- a/activerecord/test/cases/reflection_test.rb +++ b/activerecord/test/cases/reflection_test.rb @@ -33,6 +33,7 @@ require "models/admin" require "models/admin/user" require "models/user" +require "models/dats" class ReflectionTest < ActiveRecord::TestCase include ActiveRecord::Reflection @@ -716,3 +717,53 @@ def assert_reflection(klass, association, options) end end end + + +class DeprecatedReflectionsTest < ActiveRecord::TestCase + test "has_many" do + assert_non_deprecated_reflection DATS::Author, :posts + assert_deprecated_reflection DATS::Author, :deprecated_posts + end + + test "has_one" do + assert_non_deprecated_reflection DATS::Author, :post + assert_deprecated_reflection DATS::Author, :deprecated_post + end + + test "has_many :through" do + assert_non_deprecated_reflection DATS::Author, :comments + assert_non_deprecated_reflection DATS::Author, :deprecated_through + assert_non_deprecated_reflection DATS::Author, :deprecated_source + + assert_deprecated_reflection DATS::Author, :deprecated_has_many_through + assert_deprecated_reflection DATS::Author, :deprecated_all + end + + test "has_one :through" do + assert_non_deprecated_reflection DATS::Author, :comment + assert_non_deprecated_reflection DATS::Author, :deprecated_through1 + assert_non_deprecated_reflection DATS::Author, :deprecated_source1 + + assert_deprecated_reflection DATS::Author, :deprecated_has_one_through # it is through + assert_deprecated_reflection DATS::Author, :deprecated_all1 # it is through + end + + test "belongs_to" do + assert_non_deprecated_reflection DATS::Bulb, :car + assert_deprecated_reflection DATS::Bulb, :deprecated_car + end + + test "has_and_belongs_to_many" do + assert_non_deprecated_reflection DATS::Category, :posts + assert_deprecated_reflection DATS::Category, :deprecated_posts + end + + private + def assert_non_deprecated_reflection(model, name) + assert_not_predicate model.reflect_on_association(name), :deprecated? + end + + def assert_deprecated_reflection(model, name) + assert_predicate model.reflect_on_association(name), :deprecated? + end +end diff --git a/activerecord/test/cases/relations_test.rb b/activerecord/test/cases/relations_test.rb index e75a270f3ac1b..5058c0f3ecbe2 100644 --- a/activerecord/test/cases/relations_test.rb +++ b/activerecord/test/cases/relations_test.rb @@ -2,6 +2,7 @@ require "pp" require "cases/helper" +require "support/deprecated_associations_test_helpers.rb" require "models/tag" require "models/tagging" require "models/post" @@ -29,6 +30,7 @@ require "models/wheel" require "models/subscriber" require "models/cpk" +require "models/dats" class RelationTest < ActiveRecord::TestCase fixtures :authors, :author_addresses, :topics, :entrants, :developers, :people, :companies, :developers_projects, :accounts, :categories, :categorizations, :categories_posts, :posts, :comments, :tags, :taggings, :cars, :minivans, :cpk_orders @@ -2553,3 +2555,229 @@ def duel end end end + +class DeprecatedAssociationsRelationSimpleTest < ActiveRecord::TestCase + include DeprecatedAssociationsTestHelpers + + fixtures :cars + + setup do + @model = DATS::Car + end + + test "includes reports deprecated associations" do + assert_not_deprecated_association(:tyres) do + @model.includes(:tyres).to_a + end + + assert_deprecated_association(:deprecated_tyres, context: context_for_preload) do + @model.includes(:deprecated_tyres).to_a + end + end + + test "eager_load reports deprecated associations" do + assert_not_deprecated_association(:tyres) do + @model.eager_load(:tyres).to_a + end + + assert_deprecated_association(:deprecated_tyres, context: context_for_join) do + @model.eager_load(:deprecated_tyres).to_a + end + end + + test "preload reports deprecated associations" do + assert_not_deprecated_association(:tyres) do + @model.preload(:tyres).to_a + end + + assert_deprecated_association(:deprecated_tyres, context: context_for_preload) do + @model.preload(:deprecated_tyres).to_a + end + end + + test "extract_associated reports deprecated associations" do + assert_not_deprecated_association(:tyres) do + @model.extract_associated(:tyres).to_a + end + + assert_deprecated_association(:deprecated_tyres, context: context_for_preload) do + @model.extract_associated(:deprecated_tyres).to_a + end + end + + test "joins reports deprecated associations" do + assert_not_deprecated_association(:tyres) do + @model.joins(:tyres).to_a + end + + assert_deprecated_association(:deprecated_tyres, context: context_for_join) do + @model.joins(:deprecated_tyres).to_a + end + end + + test "left_outer_joins reports deprecated associations" do + assert_not_deprecated_association(:tyres) do + @model.left_outer_joins(:tyres).to_a + end + + assert_deprecated_association(:deprecated_tyres, context: context_for_join) do + @model.left_outer_joins(:deprecated_tyres).to_a + end + end + + test "left_joins reports deprecated associations" do + assert_not_deprecated_association(:tyres) do + @model.left_joins(:tyres).to_a + end + + assert_deprecated_association(:deprecated_tyres, context: context_for_join) do + @model.left_joins(:deprecated_tyres).to_a + end + end + + test "associated reports deprecated associations" do + assert_not_deprecated_association(:tyres) do + @model.where.associated(:tyres).to_a + end + + assert_deprecated_association(:deprecated_tyres, context: context_for_join) do + @model.where.associated(:deprecated_tyres).to_a + end + end + + test "missing reports deprecated associations" do + assert_not_deprecated_association(:tyres) do + @model.where.missing(:tyres).to_a + end + + assert_deprecated_association(:deprecated_tyres, context: context_for_join) do + @model.where.missing(:deprecated_tyres).to_a + end + end +end + +class DeprecatedAssociationsRelationComplexTest < ActiveRecord::TestCase + include DeprecatedAssociationsTestHelpers + + fixtures :posts, :authors + + setup do + @model = DATS::Post + end + + test "includes reports deprecated associations" do + assert_not_deprecated_association(:tyres) do + @model.includes(:comments, author: :author_favorites).to_a + end + + assert_deprecated_association(:deprecated_comments, context: context_for_preload) do + @model.includes(:deprecated_comments, author: :deprecated_author_favorites).to_a + end + + assert_deprecated_association(:deprecated_author_favorites, model: DATS::Author, context: context_for_preload) do + @model.includes(:deprecated_comments, author: :deprecated_author_favorites).to_a + end + end + + test "eager_load reports deprecated associations" do + assert_not_deprecated_association(:tyres) do + @model.eager_load(:comments, author: :author_favorites).to_a + end + + assert_deprecated_association(:deprecated_comments, context: context_for_join) do + @model.eager_load(:deprecated_comments, author: :deprecated_author_favorites).to_a + end + + assert_deprecated_association(:deprecated_author_favorites, model: DATS::Author, context: context_for_join) do + @model.eager_load(:deprecated_comments, author: :deprecated_author_favorites).to_a + end + end + + test "preload reports deprecated associations" do + assert_not_deprecated_association(:tyres) do + @model.preload(:comments, author: :author_favorites).to_a + end + + assert_deprecated_association(:deprecated_comments, context: context_for_preload) do + @model.preload(:deprecated_comments, author: :deprecated_author_favorites).to_a + end + + assert_deprecated_association(:deprecated_author_favorites, model: DATS::Author, context: context_for_preload) do + @model.preload(:deprecated_comments, author: :deprecated_author_favorites).to_a + end + end + + test "joins reports deprecated associations" do + assert_not_deprecated_association(:tyres) do + @model.joins(:comments, author: :author_favorites).to_a + end + + assert_deprecated_association(:deprecated_comments, context: context_for_join) do + @model.joins(:deprecated_comments, author: :deprecated_author_favorites).to_a + end + + assert_deprecated_association(:deprecated_author_favorites, model: DATS::Author, context: context_for_join) do + @model.joins(:deprecated_comments, author: :deprecated_author_favorites).to_a + end + end + + test "left_outer_joins reports deprecated associations" do + assert_not_deprecated_association(:tyres) do + @model.left_outer_joins(:comments, author: :author_favorites).to_a + end + + assert_deprecated_association(:deprecated_comments, context: context_for_join) do + @model.left_outer_joins(:deprecated_comments, author: :deprecated_author_favorites).to_a + end + + assert_deprecated_association(:deprecated_author_favorites, model: DATS::Author, context: context_for_join) do + @model.left_outer_joins(:deprecated_comments, author: :deprecated_author_favorites).to_a + end + end + + test "left_joins reports deprecated associations" do + assert_not_deprecated_association(:tyres) do + @model.left_joins(:comments, author: :author_favorites).to_a + end + + assert_deprecated_association(:deprecated_comments, context: context_for_join) do + @model.left_joins(:deprecated_comments, author: :deprecated_author_favorites).to_a + end + + assert_deprecated_association(:deprecated_author_favorites, model: DATS::Author, context: context_for_join) do + @model.left_joins(:deprecated_comments, author: :deprecated_author_favorites).to_a + end + end + + test "associated reports deprecated associations" do + assert_not_deprecated_association(:tyres) do + @model.where.associated(:comments, :author_favorites).to_a + end + + assert_deprecated_association(:deprecated_comments, context: context_for_join) do + @model.where.associated(:deprecated_comments, :author_favorites).to_a + end + + assert_deprecated_association(:deprecated_author_favorites, model: DATS::Author, context: context_for_through(:author_favorites)) do + # :author_favorites is correct, this one is a has_many through and + # :deprecated_author_favorites is a nested one. + @model.where.associated(:deprecated_comments, :author_favorites).to_a + end + end + + test "missing reports deprecated associations" do + assert_not_deprecated_association(:tyres) do + @model.where.missing(:comments, :author_favorites).to_a + end + + assert_deprecated_association(:deprecated_comments, context: context_for_join) do + @model.where.missing(:deprecated_comments, :author_favorites).to_a + end + + assert_deprecated_association(:deprecated_author_favorites, model: DATS::Author, context: context_for_through(:author_favorites)) do + # :author_favorites is correct, this one is a has_many through and + # :deprecated_author_favorites is a nested one we get a notification for. + @model.where.missing(:deprecated_comments, :author_favorites).to_a + end + end +end diff --git a/activerecord/test/models/dats.rb b/activerecord/test/models/dats.rb new file mode 100644 index 0000000000000..aa21bdc907c42 --- /dev/null +++ b/activerecord/test/models/dats.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +# DATS = Deprecated Associations Test Suite. +# +# Minimal models defined ad-hoc for the test suite of deprecated associations. +# They are persisted in exisiting tables for simplicity. +module DATS + def self.table_name_prefix = "" + + require_relative "dats/author" + require_relative "dats/author_favorite" + require_relative "dats/post" + require_relative "dats/category" + require_relative "dats/comment" + require_relative "dats/car" + require_relative "dats/tyre" + require_relative "dats/bulb" +end diff --git a/activerecord/test/models/dats/author.rb b/activerecord/test/models/dats/author.rb new file mode 100644 index 0000000000000..3d53480e9faf8 --- /dev/null +++ b/activerecord/test/models/dats/author.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +# DATS = Deprecated Associations Test Suite. +class DATS::Author < ActiveRecord::Base + has_many :posts, class_name: "DATS::Post", dependent: :destroy + has_many :deprecated_posts, class_name: "DATS::Post", dependent: :destroy, deprecated: true + + has_many :comments, through: :posts, class_name: "DATS::Comment", source: :comments + + has_many :deprecated_has_many_through, through: :posts, class_name: "DATS::Comment", source: :comments, deprecated: true + has_many :deprecated_through, through: :deprecated_posts, class_name: "DATS::Comment", source: :comments + has_many :deprecated_source, through: :posts, class_name: "DATS::Comment", source: :deprecated_comments + has_many :deprecated_all, through: :deprecated_posts, class_name: "DATS::Comment", source: :deprecated_comments, deprecated: true + + has_many :author_favorites, class_name: "DATS::AuthorFavorite" + has_many :deprecated_author_favorites, class_name: "DATS::AuthorFavorite", deprecated: true + has_many :deprecated_nested, through: :posts, class_name: "DATS::AuthorFavorite", source: :author_favorites + + has_one :post + has_one :deprecated_post, class_name: "DATS::Post", deprecated: true + has_one :comment, through: :post, class_name: "DATS::Comment", source: :comment + + has_one :deprecated_has_one_through, through: :post, class_name: "DATS::Comment", source: :comment, deprecated: true + has_one :deprecated_through1, through: :deprecated_post, class_name: "DATS::Comment", source: :comment + has_one :deprecated_source1, through: :post, class_name: "DATS::Comment", source: :deprecated_comment + has_one :deprecated_all1, through: :deprecated_post, class_name: "DATS::Comment", source: :deprecated_comment, deprecated: true + + has_one :author_favorite, class_name: "DATS::AuthorFavorite" + has_one :deprecated_author_favorite, class_name: "DATS::AuthorFavorite", deprecated: true + has_one :deprecated_nested1, through: :post, class_name: "DATS::AuthorFavorite", source: :author_favorite +end diff --git a/activerecord/test/models/dats/author_favorite.rb b/activerecord/test/models/dats/author_favorite.rb new file mode 100644 index 0000000000000..6fe7f9cb8a2c0 --- /dev/null +++ b/activerecord/test/models/dats/author_favorite.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +# DATS = Deprecated Associations Test Suite. +class DATS::AuthorFavorite < ActiveRecord::Base +end diff --git a/activerecord/test/models/dats/bulb.rb b/activerecord/test/models/dats/bulb.rb new file mode 100644 index 0000000000000..57f1500af3333 --- /dev/null +++ b/activerecord/test/models/dats/bulb.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +# DATS = Deprecated Associations Test Suite. +class DATS::Bulb < ActiveRecord::Base + belongs_to :car, class_name: "DATS::Car", dependent: :destroy, touch: true + belongs_to :deprecated_car, class_name: "DATS::Car", foreign_key: "car_id", dependent: :destroy, touch: true, deprecated: true +end diff --git a/activerecord/test/models/dats/car.rb b/activerecord/test/models/dats/car.rb new file mode 100644 index 0000000000000..24edec9709c14 --- /dev/null +++ b/activerecord/test/models/dats/car.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +# DATS = Deprecated Associations Test Suite. +class DATS::Car < ActiveRecord::Base + self.lock_optimistically = false + + has_many :tyres, class_name: "DATS::Tyre", dependent: :destroy + has_many :deprecated_tyres, class_name: "DATS::Tyre", dependent: :destroy, deprecated: true + + accepts_nested_attributes_for :tyres, :deprecated_tyres + + has_one :bulb, class_name: "DATS::Bulb", dependent: :destroy + has_one :deprecated_bulb, class_name: "DATS::Bulb", dependent: :destroy, deprecated: true + + accepts_nested_attributes_for :bulb, :deprecated_bulb +end diff --git a/activerecord/test/models/dats/category.rb b/activerecord/test/models/dats/category.rb new file mode 100644 index 0000000000000..0479cc958bb98 --- /dev/null +++ b/activerecord/test/models/dats/category.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +# DATS = Deprecated Associations Test Suite. +class DATS::Category < ActiveRecord::Base + self.inheritance_column = nil + + has_and_belongs_to_many :posts, class_name: "DATS::Post" + has_and_belongs_to_many :deprecated_posts, class_name: "DATS::Post", deprecated: true +end diff --git a/activerecord/test/models/dats/comment.rb b/activerecord/test/models/dats/comment.rb new file mode 100644 index 0000000000000..e31943b1f5614 --- /dev/null +++ b/activerecord/test/models/dats/comment.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +# DATS = Deprecated Associations Test Suite. +class DATS::Comment < ActiveRecord::Base + self.inheritance_column = nil +end diff --git a/activerecord/test/models/dats/post.rb b/activerecord/test/models/dats/post.rb new file mode 100644 index 0000000000000..db439e56d224c --- /dev/null +++ b/activerecord/test/models/dats/post.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +# DATS = Deprecated Associations Test Suite. +class DATS::Post < ActiveRecord::Base + self.inheritance_column = nil + + belongs_to :author + + has_many :comments, class_name: "DATS::Comment" + has_many :deprecated_comments, class_name: "DATS::Comment", deprecated: true + + has_many :author_favorites, through: :author, class_name: "DATS::AuthorFavorite", source: :deprecated_author_favorites + + has_one :comment, class_name: "DATS::Comment" + has_one :deprecated_comment, class_name: "DATS::Comment", deprecated: true + has_one :author_favorite, through: :author, class_name: "DATS::AuthorFavorite", source: :deprecated_author_favorite +end diff --git a/activerecord/test/models/dats/tyre.rb b/activerecord/test/models/dats/tyre.rb new file mode 100644 index 0000000000000..76214b59f8d51 --- /dev/null +++ b/activerecord/test/models/dats/tyre.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +# DATS = Deprecated Associations Test Suite. +class DATS::Tyre < ActiveRecord::Base +end diff --git a/activerecord/test/support/deprecated_associations_test_helpers.rb b/activerecord/test/support/deprecated_associations_test_helpers.rb new file mode 100644 index 0000000000000..c77e7dbbc0ed0 --- /dev/null +++ b/activerecord/test/support/deprecated_associations_test_helpers.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module DeprecatedAssociationsTestHelpers + private + def assert_deprecated_association(association, model: @model, context:, &) + expected_context = context + reported = false + mock = ->(reflection, context:) do + if reflection.name == association && reflection.active_record == model && expected_context == context + reported = true + end + end + ActiveRecord::Associations::Deprecation.stub(:report, mock, &) + assert reported, "Expected a notification for #{model}##{association}, but got none" + end + + def assert_not_deprecated_association(association, model: @model, &) + reported = false + mock = ->(reflection, context:) do + return if reflection.name != association + return if reflection.active_record != model + reported = true + end + ActiveRecord::Associations::Deprecation.stub(:report, mock, &) + assert_not reported, "Got a notification for #{model}##{association}, but expected none" + end + + def context_for_method(method_name) + "the method #{method_name} was invoked" + end + + def context_for_dependent + ":dependent has a side effect here" + end + + def context_for_touch + ":touch has a side effect here" + end + + def context_for_through(association, model: @model) + "referenced as nested association of the through #{model}##{association}" + end + + def context_for_preload + "referenced in query to preload records" + end + + def context_for_join + "referenced in query to join its table" + end +end diff --git a/guides/source/active_support_instrumentation.md b/guides/source/active_support_instrumentation.md index 95e7a176a9fe2..4abcc98b5821f 100644 --- a/guides/source/active_support_instrumentation.md +++ b/guides/source/active_support_instrumentation.md @@ -476,6 +476,23 @@ In practice, you cannot do much with the transaction object, but it may still be helpful for tracing database activity. For example, by tracking `transaction.uuid`. +#### `deprecated_association.active_record` + +This event is emitted when a deprecated association is accessed, and the +configured deprecated associations mode is `:notify`. + +| Key | Value | +| -------------------- | ---------------------------------------------------- | +| `:reflection` | The reflection of the association | +| `:message` | A descriptive message about the access | +| `:location` | The application-level location of the access | +| `:backtrace` | Only present if the option `:backtrace` is true | + +The `:location` is a `Thread::Backtrace::Location` object, and `:backtrace`, if +present, is an array of `Thread::Backtrace::Location` objects. These are +computed using the Active Record backtrace cleaner. In Rails applications, this +is the same as `Rails.backtrace_cleaner. + ### Action Mailer #### `deliver.action_mailer` diff --git a/guides/source/association_basics.md b/guides/source/association_basics.md index 73dd2c5552ed9..47570fd1d5088 100644 --- a/guides/source/association_basics.md +++ b/guides/source/association_basics.md @@ -2807,6 +2807,17 @@ The `:join_table` can be found on a `has_and_belongs_to_many` relationship. If the default name of the join table, based on lexical ordering, is not what you want, you can use the `:join_table` option to override the default. +#### `:deprecated` + +If true, Active Record warns every time the association is used. + +Three reporting modes are supported (`:warn`, `:raise`, and `:notify`), and +backtraces can be enabled or disabled. Defaults are `:warn` mode and disabled +backtraces. + +Please, check the documentation of `ActiveRecord::Associations::ClassMethods` +for further details. + ### Scopes Scopes allow you to specify common queries that can be referenced as method diff --git a/guides/source/configuring.md b/guides/source/configuring.md index fcab95b29db94..007820343fb39 100644 --- a/guides/source/configuring.md +++ b/guides/source/configuring.md @@ -1796,6 +1796,31 @@ config.active_record.protocol_adapters.mysql = "trilogy" If no mapping is found, the protocol is used as the adapter name. +#### `config.active_record.deprecated_associations_options` + +If present, this has to be a hash with keys `:mode` and/or `:backtrace`: + +```ruby +config.active_record.deprecated_associations_options = { mode: :notify, backtrace: true } +``` + +* In `:warn` mode, accessing the deprecated association is reported by the + Active Record logger. This is the default mode. + +* In `:raise` mode, usage raises an `ActiveRecord::DeprecatedAssociationError` + with a similar message and a clean backtrace in the exception object. + +* In `:notify` mode, a `deprecated_association.active_record` Active Support + notification is published. Please, see details about its payload in the + [Active Support Instrumentation guide](active_support_instrumentation.html). + +Backtraces are disabled by default. If `:backtrace` is true, warnings include a +clean backtrace in the message, and notifications have a `:backtrace` key in the +payload with an array of clean `Thread::Backtrace::Location` objects. Exceptions +always have a clean stack trace. + +Clean backtraces are computed using the Active Record backtrace cleaner. + ### Configuring Action Controller `config.action_controller` includes a number of configuration settings: diff --git a/railties/test/application/configuration_test.rb b/railties/test/application/configuration_test.rb index 6ba841086fc2f..3cb309f15709f 100644 --- a/railties/test/application/configuration_test.rb +++ b/railties/test/application/configuration_test.rb @@ -2903,6 +2903,25 @@ def index assert_equal true, ActiveRecord.verify_foreign_keys_for_fixtures end + test "Deprecated Associations can be configured via config.active_record.deprecated_associations_options" do + original_options = ActiveRecord.deprecated_associations_options + + # Make sure we test something. + assert_not_equal :notify, original_options[:mode] + assert_not original_options[:backtrace] + + add_to_config <<-RUBY + config.active_record.deprecated_associations_options = { mode: :notify, backtrace: true } + RUBY + + app "development" + + assert_equal :notify, ActiveRecord.deprecated_associations_options[:mode] + assert ActiveRecord.deprecated_associations_options[:backtrace] + ensure + ActiveRecord.deprecated_associations_options = original_options + end + test "ActiveRecord::Base.run_commit_callbacks_on_first_saved_instances_in_transaction is false by default for new apps" do app "development" From 6d4aa29e3c724a69362d619b7993992b0c21ba71 Mon Sep 17 00:00:00 2001 From: Xavier Noria Date: Wed, 2 Jul 2025 11:51:01 +0200 Subject: [PATCH 0267/1075] RDoc markup fix --- activerecord/lib/active_record/associations.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/activerecord/lib/active_record/associations.rb b/activerecord/lib/active_record/associations.rb index bbf7ae123869d..7c628c7e15d49 100644 --- a/activerecord/lib/active_record/associations.rb +++ b/activerecord/lib/active_record/associations.rb @@ -1131,7 +1131,7 @@ def report_deprecated_association(reflection, context:) # # ==== Backtrace # - # If :backtrace is true, warnings include a clean backtrace in the message + # If +:backtrace+ is true, warnings include a clean backtrace in the message # and notifications have a +:backtrace+ key in the payload with an array # of clean Thread::Backtrace::Location objects. Exceptions always get a # clean stack trace set. From b6963db965aa10ef9d63002f3a752cfcb20f0203 Mon Sep 17 00:00:00 2001 From: Xavier Noria Date: Wed, 2 Jul 2025 11:57:36 +0200 Subject: [PATCH 0268/1075] Delete extra blank line --- activerecord/test/cases/reflection_test.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/activerecord/test/cases/reflection_test.rb b/activerecord/test/cases/reflection_test.rb index c926625126c8b..1b1b73de27457 100644 --- a/activerecord/test/cases/reflection_test.rb +++ b/activerecord/test/cases/reflection_test.rb @@ -718,7 +718,6 @@ def assert_reflection(klass, association, options) end end - class DeprecatedReflectionsTest < ActiveRecord::TestCase test "has_many" do assert_non_deprecated_reflection DATS::Author, :posts From 71a41f82b8ebc10eab5dd84f2f3db166d253242f Mon Sep 17 00:00:00 2001 From: Franck Trouillez Date: Fri, 4 Jul 2025 10:03:55 +0200 Subject: [PATCH 0269/1075] Make `nonce: false` remove the nonce attribute This allows to use `nonce: false` to remove the nonce attribute from html options with `javascript_tag`, `javascript_include_tag` and `stylesheet_link_tag`, instead of adding `nonce="false"` --- actionview/CHANGELOG.md | 4 ++++ .../lib/action_view/helpers/asset_tag_helper.rb | 4 ++++ .../lib/action_view/helpers/javascript_helper.rb | 2 ++ actionview/test/template/asset_tag_helper_test.rb | 8 ++++++++ actionview/test/template/javascript_helper_test.rb | 12 ++++++++++++ 5 files changed, 30 insertions(+) diff --git a/actionview/CHANGELOG.md b/actionview/CHANGELOG.md index d42ab69a64b63..b335c9c9c8adc 100644 --- a/actionview/CHANGELOG.md +++ b/actionview/CHANGELOG.md @@ -1,3 +1,7 @@ +* Make `nonce: false` remove the nonce attribute from `javascript_tag`, `javascript_include_tag`, and `stylesheet_link_tag`. + + *francktrouillez* + * Add `dom_target` helper to create `dom_id`-like strings from an unlimited number of objects. diff --git a/actionview/lib/action_view/helpers/asset_tag_helper.rb b/actionview/lib/action_view/helpers/asset_tag_helper.rb index 1a004a759f2b2..e4b7ccf80c283 100644 --- a/actionview/lib/action_view/helpers/asset_tag_helper.rb +++ b/actionview/lib/action_view/helpers/asset_tag_helper.rb @@ -139,6 +139,8 @@ def javascript_include_tag(*sources) }.merge!(options) if tag_options["nonce"] == true || (!tag_options.key?("nonce") && auto_include_nonce_for_scripts) tag_options["nonce"] = content_security_policy_nonce + elsif tag_options["nonce"] == false + tag_options.delete("nonce") end content_tag("script", "", tag_options) }.join("\n").html_safe @@ -229,6 +231,8 @@ def stylesheet_link_tag(*sources) }.merge!(options) if tag_options["nonce"] == true || (!tag_options.key?("nonce") && auto_include_nonce_for_styles) tag_options["nonce"] = content_security_policy_nonce + elsif tag_options["nonce"] == false + tag_options.delete("nonce") end if apply_stylesheet_media_default && tag_options["media"].blank? diff --git a/actionview/lib/action_view/helpers/javascript_helper.rb b/actionview/lib/action_view/helpers/javascript_helper.rb index 97a17d8a933a4..47bffba8f938a 100644 --- a/actionview/lib/action_view/helpers/javascript_helper.rb +++ b/actionview/lib/action_view/helpers/javascript_helper.rb @@ -85,6 +85,8 @@ def javascript_tag(content_or_options_with_block = nil, html_options = {}, &bloc if html_options[:nonce] == true || (!html_options.key?(:nonce) && auto_include_nonce) html_options[:nonce] = content_security_policy_nonce + elsif html_options[:nonce] == false + html_options.delete(:nonce) end content_tag("script", javascript_cdata_section(content), html_options) diff --git a/actionview/test/template/asset_tag_helper_test.rb b/actionview/test/template/asset_tag_helper_test.rb index 6700ba9ee578c..7abe73007b64d 100644 --- a/actionview/test/template/asset_tag_helper_test.rb +++ b/actionview/test/template/asset_tag_helper_test.rb @@ -564,6 +564,10 @@ def test_javascript_include_tag_nonce_with_auto_nonce end end + def test_javascript_include_tag_nonce_false + assert_dom_equal %(), javascript_include_tag("bank", nonce: false) + end + def test_stylesheet_path StylePathToTag.each { |method, tag| assert_dom_equal(tag, eval(method)) } end @@ -594,6 +598,10 @@ def test_stylesheet_link_tag_nonce_with_auto_nonce end end + def test_stylesheet_link_tag_nonce_false + assert_dom_equal %(), stylesheet_link_tag("foo.css", nonce: false) + end + def test_stylesheet_link_tag_with_missing_source assert_nothing_raised { stylesheet_link_tag("missing_security_guard") diff --git a/actionview/test/template/javascript_helper_test.rb b/actionview/test/template/javascript_helper_test.rb index e3a31a272a6ee..8ef18fbe6bd83 100644 --- a/actionview/test/template/javascript_helper_test.rb +++ b/actionview/test/template/javascript_helper_test.rb @@ -87,4 +87,16 @@ def test_javascript_tag_with_auto_nonce_for_content_security_policy assert_dom_equal "", javascript_tag("alert('hello')") end + + def test_javascript_tag_nonce_true + instance_eval { def content_security_policy_nonce = "iyhD0Yc0W+c=" } + assert_dom_equal "", + javascript_tag("alert('hello')", nonce: true) + end + + def test_javascript_tag_nonce_false + instance_eval { def content_security_policy_nonce = "iyhD0Yc0W+c=" } + assert_dom_equal "", + javascript_tag("alert('hello')", nonce: false) + end end From 9496e0067222351d5618973b550451fcdc4a9edf Mon Sep 17 00:00:00 2001 From: Xavier Noria Date: Fri, 4 Jul 2025 23:35:24 +0200 Subject: [PATCH 0270/1075] Pass the user-facing reflection when notifying deprecated HABTMs --- .../active_record/associations/deprecation.rb | 11 ++++++ .../cases/associations/deprecation_test.rb | 36 +++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/activerecord/lib/active_record/associations/deprecation.rb b/activerecord/lib/active_record/associations/deprecation.rb index fb118d16c56c6..d347da4bd1370 100644 --- a/activerecord/lib/active_record/associations/deprecation.rb +++ b/activerecord/lib/active_record/associations/deprecation.rb @@ -37,6 +37,8 @@ def guard(reflection) end def report(reflection, context:) + reflection = user_facing_reflection(reflection) + message = +"The association #{reflection.active_record}##{reflection.name} is deprecated, #{context}" message << " (#{backtrace_cleaner.first_clean_frame})" @@ -75,6 +77,15 @@ def clean_locations def set_backtrace_supports_array_of_locations? @backtrace_supports_array_of_locations ||= Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("3.4.0") end + + def user_facing_reflection(reflection) + case reflection.parent_reflection + when ActiveRecord::Reflection::HasAndBelongsToManyReflection + reflection.parent_reflection + else + reflection + end + end end self.mode = :warn diff --git a/activerecord/test/cases/associations/deprecation_test.rb b/activerecord/test/cases/associations/deprecation_test.rb index 6629e1ddef542..1145d24f8e722 100644 --- a/activerecord/test/cases/associations/deprecation_test.rb +++ b/activerecord/test/cases/associations/deprecation_test.rb @@ -212,6 +212,18 @@ def teardown ActiveRecord::LogSubscriber.backtrace_cleaner = @original_backtrace_cleaner end + def assert_user_facing_reflection(model, association) + payloads = [] + callback = ->(event) { payloads << event.payload } + + ActiveSupport::Notifications.subscribed(callback, "deprecated_association.active_record") do + model.new.send(association) + end + + assert_equal 1, payloads.size + assert_equal model.reflect_on_association(association), payloads[0][:reflection] + end + test "report publishes an Active Support notification in :notify mode" do payloads = [] callback = ->(event) { payloads << event.payload } @@ -234,5 +246,29 @@ def teardown assert_equal __FILE__, payload[:backtrace][-2].path assert_equal line, payload[:backtrace][-2].lineno end + + test "has_many receives the user-facing reflection in the payload" do + assert_user_facing_reflection(DATS::Author, :deprecated_posts) + end + + test "has_one receives the user-facing reflection in the payload" do + assert_user_facing_reflection(DATS::Author, :deprecated_post) + end + + test "belongs_to receives the user-facing reflection in the payload" do + assert_user_facing_reflection(DATS::Bulb, :deprecated_car) + end + + test "has_many :through receives the user-facing reflection in the payload" do + assert_user_facing_reflection(DATS::Author, :deprecated_has_many_through) + end + + test "has_one :through receives the user-facing reflection in the payload" do + assert_user_facing_reflection(DATS::Author, :deprecated_has_one_through) + end + + test "HABTM receives the user-facing reflection in the payload" do + assert_user_facing_reflection(DATS::Category, :deprecated_posts) + end end end From 7b96382519b8bdd0156ab63c849728e38039293b Mon Sep 17 00:00:00 2001 From: Xavier Noria Date: Sat, 5 Jul 2025 08:21:33 +0200 Subject: [PATCH 0271/1075] Let collect_deprecated_nested_reflections be private This was protected because, at some point, the method was recursive, invoking itself on nested reflections. The final implementation, however, performs recursion indirectly via the public deprecated_nested_reflections. --- activerecord/lib/active_record/reflection.rb | 26 ++++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/activerecord/lib/active_record/reflection.rb b/activerecord/lib/active_record/reflection.rb index 1c61b6ff98306..ee20a368693a6 100644 --- a/activerecord/lib/active_record/reflection.rb +++ b/activerecord/lib/active_record/reflection.rb @@ -1239,19 +1239,6 @@ def actual_source_reflection # FIXME: this is a horrible name source_reflection.actual_source_reflection end - def collect_deprecated_nested_reflections - result = [] - [through_reflection, source_reflection].each do |reflection| - result << reflection if reflection.deprecated? - # Both the through and the source reflections could be through - # themselves. Nesting can go an arbitrary number of levels down. - if reflection.through_reflection? - result.concat(reflection.deprecated_nested_reflections) - end - end - result - end - private attr_reader :delegate_reflection @@ -1271,6 +1258,19 @@ def derive_class_name options[:source_type] || source_reflection.class_name end + def collect_deprecated_nested_reflections + result = [] + [through_reflection, source_reflection].each do |reflection| + result << reflection if reflection.deprecated? + # Both the through and the source reflections could be through + # themselves. Nesting can go an arbitrary number of levels down. + if reflection.through_reflection? + result.concat(reflection.deprecated_nested_reflections) + end + end + result + end + delegate_methods = AssociationReflection.public_instance_methods - public_instance_methods From 9800e59d4d3cd7e8ec244d67ead7dd824afa1013 Mon Sep 17 00:00:00 2001 From: Achmad Chun Chun Date: Sun, 6 Jul 2025 00:28:26 +0700 Subject: [PATCH 0272/1075] Remove redundant package from installation command --- guides/source/development_dependencies_install.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/guides/source/development_dependencies_install.md b/guides/source/development_dependencies_install.md index e957ac01f57e8..b616dddbfef5a 100644 --- a/guides/source/development_dependencies_install.md +++ b/guides/source/development_dependencies_install.md @@ -129,7 +129,7 @@ $ sudo npm install --global yarn To install all run: ```bash -$ sudo pacman -S sqlite mariadb libmariadbclient mariadb-clients postgresql postgresql-libs redis memcached imagemagick ffmpeg mupdf mupdf-tools poppler yarn libxml2 libvips poppler +$ sudo pacman -S sqlite mariadb libmariadbclient mariadb-clients postgresql postgresql-libs redis memcached imagemagick ffmpeg mupdf mupdf-tools poppler yarn libxml2 libvips $ sudo mariadb-install-db --user=mysql --basedir=/usr --datadir=/var/lib/mysql $ sudo systemctl start redis mariadb memcached ``` From a0605474b336e4b7c4da2a2349d79dd583b8bae9 Mon Sep 17 00:00:00 2001 From: Jeroen Versteeg Date: Sun, 6 Jul 2025 09:29:48 +0200 Subject: [PATCH 0273/1075] Fix link in guides/action_mailer_basics The link to "testing guide" used an invalid fragment identifier. --- guides/source/action_mailer_basics.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/guides/source/action_mailer_basics.md b/guides/source/action_mailer_basics.md index 203c52b2c1eee..d1d9083d17906 100644 --- a/guides/source/action_mailer_basics.md +++ b/guides/source/action_mailer_basics.md @@ -1013,7 +1013,7 @@ Previewing and Testing Mailers ------------------------------ You can find detailed instructions on how to test your mailers in the [testing -guide](testing.html#testing-your-mailers). +guide](testing.html#testing-mailers). ### Previewing Emails From 78d6ff69c54fd84fd076622b16f35faa7a5c11b0 Mon Sep 17 00:00:00 2001 From: Xavier Noria Date: Sun, 6 Jul 2025 10:33:02 +0200 Subject: [PATCH 0274/1075] Let through reflections delegate `deprecated?` to their delegate I am trying to find the right spot for HABTMs and deprecation. The current logic checking parent_reflection by hand seems suspiciously ad-hoc for me but, at the same time, I was trying to keep the option flag strictly where the user set it. However, through reflections act as their delegate for the most part: delegate_methods = AssociationReflection.public_instance_methods - public_instance_methods delegate(*delegate_methods, to: :delegate_reflection) If I sacrifice the rule above just a bit, the internal has_many of HABTMs could carry the option if present, and the resulting code looks simpler and maybe more aligned with the current design. --- activerecord/lib/active_record/associations.rb | 2 +- activerecord/lib/active_record/reflection.rb | 13 ------------- 2 files changed, 1 insertion(+), 14 deletions(-) diff --git a/activerecord/lib/active_record/associations.rb b/activerecord/lib/active_record/associations.rb index 7c628c7e15d49..6c8c0a4cd3b78 100644 --- a/activerecord/lib/active_record/associations.rb +++ b/activerecord/lib/active_record/associations.rb @@ -2035,7 +2035,7 @@ def destroy_associations hm_options[:through] = middle_reflection.name hm_options[:source] = join_model.right_reflection.name - [:before_add, :after_add, :before_remove, :after_remove, :autosave, :validate, :join_table, :class_name, :extend, :strict_loading].each do |k| + [:before_add, :after_add, :before_remove, :after_remove, :autosave, :validate, :join_table, :class_name, :extend, :strict_loading, :deprecated].each do |k| hm_options[k] = options[k] if options.key?(k) end diff --git a/activerecord/lib/active_record/reflection.rb b/activerecord/lib/active_record/reflection.rb index ee20a368693a6..2c6ad15e152fc 100644 --- a/activerecord/lib/active_record/reflection.rb +++ b/activerecord/lib/active_record/reflection.rb @@ -1217,19 +1217,6 @@ def add_as_through(seed) collect_join_reflections(seed + [self]) end - def deprecated? - unless defined?(@deprecated) - @deprecated = - if parent_reflection.is_a?(HasAndBelongsToManyReflection) - parent_reflection.deprecated? - else - delegate_reflection.deprecated? - end - end - - @deprecated - end - def deprecated_nested_reflections @deprecated_nested_reflections ||= collect_deprecated_nested_reflections end From 4621f7131d2afce2828dc498530adebc229bb743 Mon Sep 17 00:00:00 2001 From: Xavier Noria Date: Sun, 6 Jul 2025 10:54:23 +0200 Subject: [PATCH 0275/1075] KISS ActiveRecord::Associations::Deprecation.user_facing_reflection Another one pursuing a nice spot for HABTMs and deprecation. Do not feel quite confortable hard-coding this logic here, let's keep it simple. --- activerecord/lib/active_record/associations/deprecation.rb | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/activerecord/lib/active_record/associations/deprecation.rb b/activerecord/lib/active_record/associations/deprecation.rb index d347da4bd1370..344f7b46a381d 100644 --- a/activerecord/lib/active_record/associations/deprecation.rb +++ b/activerecord/lib/active_record/associations/deprecation.rb @@ -79,12 +79,7 @@ def set_backtrace_supports_array_of_locations? end def user_facing_reflection(reflection) - case reflection.parent_reflection - when ActiveRecord::Reflection::HasAndBelongsToManyReflection - reflection.parent_reflection - else - reflection - end + reflection.active_record.reflect_on_association(reflection.name) end end From c87bd82ba11a3b2593809021eb0d30bdad2f208c Mon Sep 17 00:00:00 2001 From: zzak Date: Mon, 31 Mar 2025 19:57:59 +0900 Subject: [PATCH 0276/1075] Deprecate ActionController.escape_json_responses= at the method Instead of emitting the deprecation in an initializer, we should emit the deprecation when calling the writer method. In order to reduce warnings when users are updating their apps to use the new default `true`, we check the value before emitting. This includes the class attribute method as well. In order to set the value for the internal attribute, we need to find out the name of the internal setter. https://github.com/rails/rails/blob/1c094d762d25805cc2d110ee2f7b54b33352e293/activesupport/lib/active_support/core_ext/class/attribute.rb#L95-L102 In the future, it would be nice if `class_attribute` and `mattr_*`, etc had a way to easily deprecate parts of the methods being defined, or provide a callback (so we can emit warnings based on the value, etc). Co-authored-by: Alex Ghiculescu Co-authored-by: Jean Boussier --- .../lib/action_controller/metal/renderers.rb | 16 +++- actionpack/lib/action_controller/railtie.rb | 13 --- .../test/controller/render_json_test.rb | 94 ++++++++++++++++++- 3 files changed, 105 insertions(+), 18 deletions(-) diff --git a/actionpack/lib/action_controller/metal/renderers.rb b/actionpack/lib/action_controller/metal/renderers.rb index 48d2b34213196..26efdddb37385 100644 --- a/actionpack/lib/action_controller/metal/renderers.rb +++ b/actionpack/lib/action_controller/metal/renderers.rb @@ -27,9 +27,23 @@ module Renderers # Default values are `:json`, `:js`, `:xml`. RENDERERS = Set.new + module DeprecatedEscapeJsonResponses # :nodoc: + def escape_json_responses=(value) + if value + ActionController.deprecator.warn(<<~MSG.squish) + Setting action_controller.escape_json_responses = true is deprecated and will have no effect in Rails 8.2. + Set it to `false`, or remove the config. + MSG + end + super + end + end + included do class_attribute :_renderers, default: Set.new.freeze - class_attribute :escape_json_responses, instance_accessor: false, default: true + class_attribute :escape_json_responses, instance_writer: false, instance_accessor: false, default: true + + singleton_class.prepend DeprecatedEscapeJsonResponses end # Used in ActionController::Base and ActionController::API to include all diff --git a/actionpack/lib/action_controller/railtie.rb b/actionpack/lib/action_controller/railtie.rb index 2323df7378663..4b0619402e5ad 100644 --- a/actionpack/lib/action_controller/railtie.rb +++ b/actionpack/lib/action_controller/railtie.rb @@ -133,18 +133,5 @@ class Railtie < Rails::Railtie # :nodoc: ActionController::TestCase.executor_around_each_request = app.config.active_support.executor_around_test_case end end - - initializer "action_controller.escape_json_responses_deprecated_warning" do - config.after_initialize do - ActiveSupport.on_load(:action_controller) do - if ActionController::Base.escape_json_responses - ActionController.deprecator.warn(<<~MSG.squish) - Setting action_controller.escape_json_responses = true is deprecated and will have no effect in Rails 8.2. - Set it to `false` or use `config.load_defaults(8.1)`. - MSG - end - end - end - end end end diff --git a/actionpack/test/controller/render_json_test.rb b/actionpack/test/controller/render_json_test.rb index bfb03dae2111a..900ed069e4feb 100644 --- a/actionpack/test/controller/render_json_test.rb +++ b/actionpack/test/controller/render_json_test.rb @@ -122,10 +122,96 @@ def test_render_json_with_callback_escapes_js_chars end def test_render_json_with_new_default_and_without_callback_does_not_escape_js_chars - TestController.with(escape_json_responses: false) do - get :render_json_unsafe_chars_without_callback - assert_equal %({"hello":"\u2028\u2029`. Read more about XSS and injection later on. * The attacker lures the victim to the infected page with the JavaScript code. By viewing the page, the victim's browser will change the session ID to the trap session ID. @@ -571,7 +571,7 @@ def legacy end ``` -This will redirect the user to the main action if they tried to access a legacy action. The intention was to preserve the URL parameters to the legacy action and pass them to the main action. However, it can be exploited by attacker if they included a host key in the URL: +This will redirect the user to the main action if they try to access a legacy action. The intention was to preserve the URL parameters to the legacy action and pass them to the main action. However, it can be exploited by an attacker if they include a host key in the URL: ``` http://www.example.com/site/legacy?param1=xy¶m2=23&host=www.attacker.com @@ -601,7 +601,7 @@ def sanitize_filename(filename) # NOTE: File.basename doesn't work right with Windows paths on Unix # get only the filename, not the whole path name.sub!(/\A.*(\\|\/)/, "") - # Finally, replace all non alphanumeric, underscore + # Finally, replace all non-alphanumeric, underscore # or periods with underscore name.gsub!(/[^\w.-]/, "_") end @@ -639,7 +639,7 @@ raise if basename != File.expand_path(File.dirname(filename)) send_file filename, disposition: "inline" ``` -Another (additional) approach is to store the file names in the database and name the files on the disk after the ids in the database. This is also a good approach to avoid possible code in an uploaded file to be executed. The `attachment_fu` plugin does this in a similar way. +Another (additional) approach is to store the file names in the database and name the files on the disk after the ids in the database. This is also a good approach to avoid possible code in an uploaded file from being executed. The `attachment_fu` plugin does this in a similar way. User Management --------------- @@ -688,7 +688,7 @@ Depending on your web application, there may be more ways to hijack the user's a ### CAPTCHAs -INFO: _A CAPTCHA is a challenge-response test to determine that the response is not generated by a computer. It is often used to protect registration forms from attackers and comment forms from automatic spam bots by asking the user to type the letters of a distorted image. This is the positive CAPTCHA, but there is also the negative CAPTCHA. The idea of a negative CAPTCHA is not for a user to prove that they are human, but reveal that a robot is a robot._ +INFO: _A CAPTCHA is a challenge-response test to determine that the response is not generated by a computer. It is often used to protect registration forms from attackers and comment forms from automatic spam bots by asking the user to type the letters of a distorted image. This is the positive CAPTCHA, but there is also the negative CAPTCHA. The idea of a negative CAPTCHA is not for a user to prove that they are human, but to reveal that a robot is a robot._ A popular positive CAPTCHA API is [reCAPTCHA](https://developers.google.com/recaptcha/) which displays two distorted images of words from old books. It also adds an angled line, rather than a distorted background and high levels of warping on the text as earlier CAPTCHAs did, because the latter were broken. As a bonus, using reCAPTCHA helps to digitize old books. [ReCAPTCHA](https://github.com/ambethia/recaptcha/) is also a Rails plug-in with the same name as the API. @@ -699,13 +699,13 @@ Most bots are really naive. They crawl the web and put their spam into every for Note that negative CAPTCHAs are only effective against naive bots and won't suffice to protect critical applications from targeted bots. Still, the negative and positive CAPTCHAs can be combined to increase the performance, e.g., if the "honeypot" field is not empty (bot detected), you won't need to verify the positive CAPTCHA, which would require an HTTPS request to Google ReCaptcha before computing the response. -Here are some ideas how to hide honeypot fields by JavaScript and/or CSS: +Here are some ideas on how to hide honeypot fields by JavaScript and/or CSS: -* position the fields off of the visible area of the page +* position the fields off the visible area of the page * make the elements very small or color them the same as the background of the page * leave the fields displayed, but tell humans to leave them blank -The most simple negative CAPTCHA is one hidden honeypot field. On the server side, you will check the value of the field: If it contains any text, it must be a bot. Then, you can either ignore the post or return a positive result, but not saving the post to the database. This way the bot will be satisfied and moves on. +The simplest negative CAPTCHA is one hidden honeypot field. On the server side, you will check the value of the field: If it contains any text, it must be a bot. Then, you can either ignore the post or return a positive result, but not save the post to the database. This way, the bot will be satisfied and move on. You can find more sophisticated negative CAPTCHAs in Ned Batchelder's [blog post](https://nedbatchelder.com/text/stopbots.html): @@ -719,7 +719,7 @@ Note that this protects you only from automatic bots, targeted tailor-made bots WARNING: _Tell Rails not to put passwords in the log files._ -By default, Rails logs all requests being made to the web application. But log files can be a huge security issue, as they may contain login credentials, credit card numbers et cetera. When designing a web application security concept, you should also think about what will happen if an attacker got (full) access to the web server. Encrypting secrets and passwords in the database will be quite useless, if the log files list them in clear text. You can _filter certain request parameters from your log files_ by appending them to [`config.filter_parameters`][] in the application configuration. These parameters will be marked [FILTERED] in the log. +By default, Rails logs all requests being made to the web application. But log files can be a huge security issue, as they may contain login credentials, credit card numbers et cetera. When designing a web application security concept, you should also think about what will happen if an attacker gets (full) access to the web server. Encrypting secrets and passwords in the database will be quite useless, if the log files list them in clear text. You can _filter certain request parameters from your log files_ by appending them to [`config.filter_parameters`][] in the application configuration. These parameters will be marked [FILTERED] in the log. ```ruby config.filter_parameters << :password @@ -821,7 +821,7 @@ INFO: _Thanks to clever methods, this is hardly a problem in most Rails applicat #### Introduction -SQL injection attacks aim at influencing database queries by manipulating web application parameters. A popular goal of SQL injection attacks is to bypass authorization. Another goal is to carry out data manipulation or reading arbitrary data. Here is an example of how not to use user input data in a query: +SQL injection attacks aim at influencing database queries by manipulating web application parameters. A popular goal of SQL injection attacks is to bypass authorization. Another goal is to carry out data manipulation or read arbitrary data. Here is an example of how not to use user input data in a query: ```ruby Project.where("name = '#{params[:name]}'") @@ -849,7 +849,7 @@ If an attacker enters `' OR '1'='1` as the name, and `' OR '2'>'1` as the passwo SELECT * FROM users WHERE login = '' OR '1'='1' AND password = '' OR '2'>'1' LIMIT 1 ``` -This will simply find the first record in the database, and grants access to this user. +This will simply find the first record in the database and grant access to this user. #### Unauthorized Reading @@ -903,7 +903,7 @@ Additionally, you can split and chain conditionals valid for your use case: Model.where(zip_code: entered_zip_code).where("quantity >= ?", entered_quantity).first ``` -Note the previous mentioned countermeasures are only available in model instances. You can +Note that the previously mentioned countermeasures are only available in model instances. You can try [`sanitize_sql`][] elsewhere. _Make it a habit to think about the security consequences when using an external string in SQL_. @@ -921,7 +921,7 @@ The most common entry points are message posts, user comments, and guest books, XSS attacks work like this: An attacker injects some code, the web application saves it and displays it on a page, later presented to a victim. Most XSS examples simply display an alert box, but it is more powerful than that. XSS can steal the cookie, hijack the session, redirect the victim to a fake website, display advertisements for the benefit of the attacker, change elements on the website to get confidential information or install malicious software through security holes in the web browser. -During the second half of 2007, there were 88 vulnerabilities reported in Mozilla browsers, 22 in Safari, 18 in IE, and 12 in Opera. The Symantec Global Internet Security threat report also documented 239 browser plug-in vulnerabilities in the last six months of 2007. [Mpack](https://www.pandasecurity.com/en/mediacenter/malware/mpack-uncovered/) is a very active and up-to-date attack framework which exploits these vulnerabilities. For criminal hackers, it is very attractive to exploit an SQL-Injection vulnerability in a web application framework and insert malicious code in every textual table column. In April 2008 more than 510,000 sites were hacked like this, among them the British government, United Nations, and many more high profile targets. +During the second half of 2007, there were 88 vulnerabilities reported in Mozilla browsers, 22 in Safari, 18 in IE, and 12 in Opera. The Symantec Global Internet Security threat report also documented 239 browser plug-in vulnerabilities in the last six months of 2007. [Mpack](https://www.pandasecurity.com/en/mediacenter/malware/mpack-uncovered/) is a very active and up-to-date attack framework which exploits these vulnerabilities. For criminal hackers, it is very attractive to exploit an SQL injection vulnerability in a web application framework and insert malicious code in every textual table column. In April 2008 more than 510,000 sites were hacked like this, among them the British government, United Nations, and many more high-profile targets. #### HTML/JavaScript Injection @@ -964,7 +964,7 @@ You can mitigate these attacks (in the obvious way) by adding the **httpOnly** f ##### Defacement -With web page defacement an attacker can do a lot of things, for example, present false information or lure the victim on the attacker's website to steal the cookie, login credentials, or other sensitive data. The most popular way is to include code from external sources by iframes: +With web page defacement, an attacker can do a lot of things, for example, present false information or lure the victim to the attacker's website to steal the cookie, login credentials, or other sensitive data. The most popular way is to include code from external sources by iframes: ```html @@ -972,9 +972,9 @@ With web page defacement an attacker can do a lot of things, for example, presen This loads arbitrary HTML and/or JavaScript from an external source and embeds it as part of the site. This `iframe` is taken from an actual attack on legitimate Italian sites using the [Mpack attack framework](https://isc.sans.edu/diary/MPack+Analysis/3015). Mpack tries to install malicious software through security holes in the web browser - very successfully, 50% of the attacks succeed. -A more specialized attack could overlap the entire website or display a login form, which looks the same as the site's original, but transmits the username and password to the attacker's site. Or it could use CSS and/or JavaScript to hide a legitimate link in the web application, and display another one at its place which redirects to a fake website. +A more specialized attack could overlap the entire website or display a login form, which looks the same as the site's original, but transmits the username and password to the attacker's site. Or it could use CSS and/or JavaScript to hide a legitimate link in the web application, and display another one in its place, which redirects to a fake website. -Reflected injection attacks are those where the payload is not stored to present it to the victim later on, but included in the URL. Especially search forms fail to escape the search string. The following link presented a page which stated that "George Bush appointed a 9 year old boy to be the chairperson...": +Reflected injection attacks are those where the payload is not stored to present it to the victim later on, but is included in the URL. Especially search forms fail to escape the search string. The following link presented a page which stated that "George Bush appointed a 9 year old boy to be the chairperson...": ``` http://www.cbsnews.com/stories/2002/02/15/weather_local/main501644.shtml?zipcode=1--> @@ -987,7 +987,7 @@ _It is very important to filter malicious input, but it is also important to esc Especially for XSS, it is important to do _permitted input filtering instead of restricted_. Permitted list filtering states the values allowed as opposed to the values not allowed. Restricted lists are never complete. -Imagine a restricted list deletes `"script"` from the user input. Now the attacker injects `""`, and after the filter, `" <%= yield %> + diff --git a/actionpack/lib/action_dispatch/middleware/templates/rescues/missing_exact_template.html.erb b/actionpack/lib/action_dispatch/middleware/templates/rescues/missing_exact_template.html.erb index e5563034143bf..ffafefcd29d91 100644 --- a/actionpack/lib/action_dispatch/middleware/templates/rescues/missing_exact_template.html.erb +++ b/actionpack/lib/action_dispatch/middleware/templates/rescues/missing_exact_template.html.erb @@ -1,4 +1,5 @@
+ <%= render "rescues/copy_button" %>

No view template for interactive request

diff --git a/actionpack/lib/action_dispatch/middleware/templates/rescues/missing_template.html.erb b/actionpack/lib/action_dispatch/middleware/templates/rescues/missing_template.html.erb index 6081dcf3759b6..e227c5dca7406 100644 --- a/actionpack/lib/action_dispatch/middleware/templates/rescues/missing_template.html.erb +++ b/actionpack/lib/action_dispatch/middleware/templates/rescues/missing_template.html.erb @@ -1,4 +1,5 @@
+ <%= render "rescues/copy_button" %>

Template is missing

diff --git a/actionpack/lib/action_dispatch/middleware/templates/rescues/routing_error.html.erb b/actionpack/lib/action_dispatch/middleware/templates/rescues/routing_error.html.erb index 25264a41dc514..85ff514f158db 100644 --- a/actionpack/lib/action_dispatch/middleware/templates/rescues/routing_error.html.erb +++ b/actionpack/lib/action_dispatch/middleware/templates/rescues/routing_error.html.erb @@ -1,4 +1,5 @@
+ <%= render "rescues/copy_button" %>

Routing Error

diff --git a/actionpack/lib/action_dispatch/middleware/templates/rescues/template_error.html.erb b/actionpack/lib/action_dispatch/middleware/templates/rescues/template_error.html.erb index 4ee6ad0c908e9..d203a5d111302 100644 --- a/actionpack/lib/action_dispatch/middleware/templates/rescues/template_error.html.erb +++ b/actionpack/lib/action_dispatch/middleware/templates/rescues/template_error.html.erb @@ -1,4 +1,5 @@
+ <%= render "rescues/copy_button" %>

<%= @exception_wrapper.exception_name %> in <%= @request.parameters["controller"].camelize if @request.parameters["controller"] %>#<%= @request.parameters["action"] %> diff --git a/actionpack/lib/action_dispatch/middleware/templates/rescues/unknown_action.html.erb b/actionpack/lib/action_dispatch/middleware/templates/rescues/unknown_action.html.erb index 2c2d1a94d751a..30b9f7edd7388 100644 --- a/actionpack/lib/action_dispatch/middleware/templates/rescues/unknown_action.html.erb +++ b/actionpack/lib/action_dispatch/middleware/templates/rescues/unknown_action.html.erb @@ -1,4 +1,5 @@
+ <%= render "rescues/copy_button" %>

Unknown action

diff --git a/actionpack/test/dispatch/debug_exceptions_test.rb b/actionpack/test/dispatch/debug_exceptions_test.rb index 257e83f42c23a..57ecc879cc49b 100644 --- a/actionpack/test/dispatch/debug_exceptions_test.rb +++ b/actionpack/test/dispatch/debug_exceptions_test.rb @@ -924,4 +924,37 @@ def self.build_app(app, *args) assert_select "#container p", /Showing #{__FILE__} where line #\d+ raised/ assert_select "#container code", /undefined local variable or method ['`]string”'/ end + + test "includes copy button in error pages" do + @app = DevelopmentApp + + get "/", headers: { "action_dispatch.show_exceptions" => :all } + assert_response 500 + + assert_match %r{}, body + assert_match %r{}m, 1] + assert_match %r{Third error}, script_content + assert_match %r{Caused by:.*Second error}m, script_content + end end From 1ec4d9ef31060a69884c1860620c1b26b455d1f9 Mon Sep 17 00:00:00 2001 From: Les Nightingill Date: Tue, 12 Aug 2025 15:02:33 -0700 Subject: [PATCH 0445/1075] Update action_view_overview.md (#55411) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update action_view_overview.md Clarifies that a single counter variable is available when instance of different ActiveRecord subclasses are included in the collection. This is the behaviour specified in the test in rails/actionpack/test/controller/render.rb called test_partial_collection_shorthand_with_different_types_of_records * Update guides/source/action_view_overview.md --------- Co-authored-by: Matheus Richard Co-authored-by: Rafael Mendonça França --- guides/source/action_view_overview.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/guides/source/action_view_overview.md b/guides/source/action_view_overview.md index c1ef680cb084d..c5d7a6d8c466c 100644 --- a/guides/source/action_view_overview.md +++ b/guides/source/action_view_overview.md @@ -524,6 +524,8 @@ view, starting with a value of `0` on the first render. This also works when the local variable name is changed using the `as:` option. So if you did `as: :item`, the counter variable would be `item_counter`. +NOTE: When rendering collections with instances of different models, the counter variable increments for each partial, regardless of the class of the model being rendered. + Note: The following two sections, [Strict Locals](#strict-locals) and [Local Assigns with Pattern Matching](#local-assigns-with-pattern-matching) are more advanced features of using partials, included here for completeness. From 97c165657256989a4841ec728c4cb161f022436e Mon Sep 17 00:00:00 2001 From: Andrew Bloyce Date: Wed, 16 Oct 2024 14:31:51 +1000 Subject: [PATCH 0446/1075] prevent double scrollbar issue + sticky header --- railties/lib/rails/templates/rails/mailers/email.html.erb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/railties/lib/rails/templates/rails/mailers/email.html.erb b/railties/lib/rails/templates/rails/mailers/email.html.erb index 674531b15fc71..2b91f19005271 100644 --- a/railties/lib/rails/templates/rails/mailers/email.html.erb +++ b/railties/lib/rails/templates/rails/mailers/email.html.erb @@ -9,6 +9,8 @@ body { margin: 0; + display: flex; + flex-direction: column; } header { @@ -18,7 +20,6 @@ background: white; font: 12px "Lucida Grande", sans-serif; border-bottom: 1px solid #dedede; - overflow: hidden; } dl { From bfbd623399e86a2728e79a27cb8a946db7acadac Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Tue, 12 Aug 2025 12:08:51 +0200 Subject: [PATCH 0447/1075] Refactor Active Support Uncountables to not be an Array subclass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I wrote at length on the impact of using instance variables in an Array subclass (or some other core types) in https://byroot.github.io/ruby/performance/2025/08/11/unlocking-ractors-generic-variables.html Since uncountables are accessed by some low-level hotspots like `pluralize`, it's beneficial not to rely on the generic ivar table. the difference isn't huge, because `pluralize` is quite a costly operation, but having less generic ivars has non-local performance benefits. NB: don't read this as pluralize becoming 9% faster. That's not what this benchmark shows. ``` ruby 3.4.2 (2025-02-15 revision d2930f8e7a) +YJIT +PRISM [arm64-darwin24] Warming up -------------------------------------- after 134.737k i/100ms Calculating ------------------------------------- after 1.394M (± 0.3%) i/s (717.60 ns/i) - 7.006M in 5.027775s Comparison: before: 1282919.6 i/s after: 1393537.2 i/s - 1.09x faster ``` ```ruby require "bundler/inline" gemfile do gem "rails", path: "." gem "benchmark-ips" end class GenIvar < Array def initialize @ivar = 1 end end gens = 500_000.times.map { GenIvar.new } ActiveSupport::Inflector.inflections.uncountable << "test" STAGE = ENV["STAGE"] Benchmark.ips do |x| x.report(STAGE || "pluralize") { ActiveSupport::Inflector.pluralize("test") } x.compare!(order: :baseline) if STAGE x.save!("/tmp/bench-pluralize") end end ``` --- .../active_support/inflector/inflections.rb | 24 ++++++++++++++----- activesupport/test/inflector_test.rb | 2 +- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/activesupport/lib/active_support/inflector/inflections.rb b/activesupport/lib/active_support/inflector/inflections.rb index 5a1b64a5e6279..39d9e343bfb26 100644 --- a/activesupport/lib/active_support/inflector/inflections.rb +++ b/activesupport/lib/active_support/inflector/inflections.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "concurrent/map" +require "active_support/core_ext/module/delegation" require "active_support/i18n" module ActiveSupport @@ -30,24 +31,35 @@ module Inflector class Inflections @__instance__ = Concurrent::Map.new - class Uncountables < Array + class Uncountables # :nodoc: + include Enumerable + + delegate :each, :pop, :empty?, :to_s, :==, :to_a, :to_ary, to: :@members + def initialize + @members = [] @regex_array = [] - super end def delete(entry) - super entry + @members.delete(entry) @regex_array.delete(to_regex(entry)) end - def <<(*word) - add(word) + def <<(word) + word = word.downcase + @members << word + @regex_array << to_regex(word) + self + end + + def flatten + @members.dup end def add(words) words = words.flatten.map(&:downcase) - concat(words) + @members.concat(words) @regex_array += words.map { |word| to_regex(word) } self end diff --git a/activesupport/test/inflector_test.rb b/activesupport/test/inflector_test.rb index b4b20eb630b0a..d53bc4751cae0 100644 --- a/activesupport/test/inflector_test.rb +++ b/activesupport/test/inflector_test.rb @@ -623,7 +623,7 @@ def test_clear_all_resets_camelize_and_underscore_regexes assert_equal [], inflect.singulars assert_equal [], inflect.plurals - assert_equal [], inflect.uncountables + assert_equal [], inflect.uncountables.to_a # restore all the inflections singulars.reverse_each { |singular| inflect.singular(*singular) } From a758c2267ca70b32c045194d74baa5286e6d1c24 Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Wed, 13 Aug 2025 12:07:21 +0200 Subject: [PATCH 0448/1075] Optimize `#uncountable?` check. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It's much faster to match a single big regexp built with `Regexp.union` than to match many smaller regexps with `.any?`. Before: ``` ruby 3.4.2 (2025-02-15 revision d2930f8e7a) +YJIT +PRISM [arm64-darwin24] Calculating ------------------------------------- regular 275.598k (± 0.8%) i/s (3.63 μs/i) - 1.401M in 5.082747s irregular 1.319M (± 0.9%) i/s (758.29 ns/i) - 6.658M in 5.049382s uncountable 2.428M (± 0.8%) i/s (411.87 ns/i) - 12.315M in 5.072615s ``` After: ``` ruby 3.4.2 (2025-02-15 revision d2930f8e7a) +YJIT +PRISM [arm64-darwin24] Calculating ------------------------------------- regular 255.878k (± 1.0%) i/s (3.91 μs/i) - 1.302M in 5.088179s irregular 883.098k (± 0.7%) i/s (1.13 μs/i) - 4.438M in 5.025973s uncountable 1.523M (± 1.1%) i/s (656.60 ns/i) - 7.676M in 5.040342s ``` Benchmark: ```ruby require "bundler/inline" gemfile do gem "rails", path: "." gem "benchmark-ips" end Benchmark.ips do |x| x.report("regular") { ActiveSupport::Inflector.pluralize("test") } x.report("irregular") { ActiveSupport::Inflector.pluralize("zombie") } x.report("uncountable") { ActiveSupport::Inflector.pluralize("sheep") } end ``` --- .../active_support/inflector/inflections.rb | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/activesupport/lib/active_support/inflector/inflections.rb b/activesupport/lib/active_support/inflector/inflections.rb index 39d9e343bfb26..7e92aa95a5bb6 100644 --- a/activesupport/lib/active_support/inflector/inflections.rb +++ b/activesupport/lib/active_support/inflector/inflections.rb @@ -38,18 +38,18 @@ class Uncountables # :nodoc: def initialize @members = [] - @regex_array = [] + @pattern = nil end def delete(entry) @members.delete(entry) - @regex_array.delete(to_regex(entry)) + @pattern = nil end def <<(word) word = word.downcase @members << word - @regex_array << to_regex(word) + @pattern = nil self end @@ -60,18 +60,17 @@ def flatten def add(words) words = words.flatten.map(&:downcase) @members.concat(words) - @regex_array += words.map { |word| to_regex(word) } + @pattern = nil self end def uncountable?(str) - @regex_array.any? { |regex| regex.match? str } - end - - private - def to_regex(string) - /\b#{::Regexp.escape(string)}\Z/i + if @pattern.nil? + members_pattern = Regexp.union(@members.map { |w| /#{Regexp.escape(w)}/i }) + @pattern = /\b#{members_pattern}\Z/i end + @pattern.match?(str) + end end def self.instance(locale = :en) From 2ab34cd5141336432c3f56ec2a626a287819f6b6 Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Wed, 13 Aug 2025 12:14:30 +0200 Subject: [PATCH 0449/1075] Optimize access to the `en` inflections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `en` inflector is almost always the one used for itnernal framework inflections, hence it's called much more than any other. Hence it's worth having a fast path for it. Before: ``` ruby 3.4.2 (2025-02-15 revision d2930f8e7a) +YJIT +PRISM [arm64-darwin24] Calculating ------------------------------------- regular 278.450k (± 1.1%) i/s (3.59 μs/i) - 1.401M in 5.030803s irregular 1.320M (± 0.9%) i/s (757.36 ns/i) - 6.617M in 5.012038s uncountable 2.417M (± 0.5%) i/s (413.72 ns/i) - 12.172M in 5.035810s ``` After: ``` ruby 3.4.2 (2025-02-15 revision d2930f8e7a) +YJIT +PRISM [arm64-darwin24] Calculating ------------------------------------- regular 297.666k (± 1.3%) i/s (3.36 μs/i) - 1.509M in 5.068638s irregular 1.975M (± 0.6%) i/s (506.28 ns/i) - 9.939M in 5.031926s uncountable 6.078M (± 0.5%) i/s (164.54 ns/i) - 30.504M in 5.019120s ``` Benchmark: ``` require "bundler/inline" gemfile do gem "rails", path: "." gem "benchmark-ips" end Benchmark.ips do |x| x.report("regular") { ActiveSupport::Inflector.pluralize("test") } x.report("irregular") { ActiveSupport::Inflector.pluralize("zombie") } x.report("uncountable") { ActiveSupport::Inflector.pluralize("sheep") } end ``` --- .../lib/active_support/inflector/inflections.rb | 5 +++++ activesupport/test/inflector_test.rb | 10 +++++++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/activesupport/lib/active_support/inflector/inflections.rb b/activesupport/lib/active_support/inflector/inflections.rb index 7e92aa95a5bb6..24d0e6e241906 100644 --- a/activesupport/lib/active_support/inflector/inflections.rb +++ b/activesupport/lib/active_support/inflector/inflections.rb @@ -30,6 +30,7 @@ module Inflector # before any of the rules that may already have been loaded. class Inflections @__instance__ = Concurrent::Map.new + @__en_instance__ = nil class Uncountables # :nodoc: include Enumerable @@ -74,10 +75,14 @@ def uncountable?(str) end def self.instance(locale = :en) + return @__en_instance__ ||= new if locale == :en + @__instance__[locale] ||= new end def self.instance_or_fallback(locale) + return @__en_instance__ ||= new if locale == :en + I18n.fallbacks[locale].each do |k| return @__instance__[k] if @__instance__.key?(k) end diff --git a/activesupport/test/inflector_test.rb b/activesupport/test/inflector_test.rb index d53bc4751cae0..5ff7559fb6f31 100644 --- a/activesupport/test/inflector_test.rb +++ b/activesupport/test/inflector_test.rb @@ -16,12 +16,16 @@ def setup # This helper is implemented by setting @__instance__ because in some tests # there are module functions that access ActiveSupport::Inflector.inflections, # so we need to replace the singleton itself. - @original_inflections = ActiveSupport::Inflector::Inflections.instance_variable_get(:@__instance__)[:en] - ActiveSupport::Inflector::Inflections.instance_variable_set(:@__instance__, en: @original_inflections.dup) + @original_inflections = ActiveSupport::Inflector::Inflections.instance_variable_get(:@__instance__) + @original_inflection_en = ActiveSupport::Inflector::Inflections.instance_variable_get(:@__en_instance__) + + ActiveSupport::Inflector::Inflections.instance_variable_set(:@__instance__, {}) + ActiveSupport::Inflector::Inflections.instance_variable_set(:@__en_instance__, @original_inflection_en.dup) end def teardown - ActiveSupport::Inflector::Inflections.instance_variable_set(:@__instance__, en: @original_inflections) + ActiveSupport::Inflector::Inflections.instance_variable_set(:@__instance__, @original_inflections) + ActiveSupport::Inflector::Inflections.instance_variable_set(:@__en_instance__, @original_inflection_en) end def test_pluralize_plurals From ae614d3ea602395221648cfe5d1b086c79f0880e Mon Sep 17 00:00:00 2001 From: Petrik Date: Wed, 13 Aug 2025 12:42:20 +0200 Subject: [PATCH 0450/1075] Squash routing output in Getting Started guide [ci-skip] This prevents some unexpected line wrapping. --- guides/source/getting_started.md | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/guides/source/getting_started.md b/guides/source/getting_started.md index 9d5e581806e1d..7e97d564b4b41 100644 --- a/guides/source/getting_started.md +++ b/guides/source/getting_started.md @@ -841,15 +841,15 @@ You'll see this in the output which are the routes generated by `resources :products` ``` - Prefix Verb URI Pattern Controller#Action - products GET /products(.:format) products#index - POST /products(.:format) products#create - new_product GET /products/new(.:format) products#new - edit_product GET /products/:id/edit(.:format) products#edit - product GET /products/:id(.:format) products#show - PATCH /products/:id(.:format) products#update - PUT /products/:id(.:format) products#update - DELETE /products/:id(.:format) products#destroy + Prefix Verb URI Pattern Controller#Action + products GET /products(.:format) products#index + POST /products(.:format) products#create + new_product GET /products/new(.:format) products#new +edit_product GET /products/:id/edit(.:format) products#edit + product GET /products/:id(.:format) products#show + PATCH /products/:id(.:format) products#update + PUT /products/:id(.:format) products#update + DELETE /products/:id(.:format) products#destroy ``` You'll also see routes from other built-in Rails features like health checks. @@ -1076,9 +1076,9 @@ Rails provides helper methods for generating paths and URLs. When you run helpers you can use for generating URLs with Ruby code. ``` - Prefix Verb URI Pattern Controller#Action - products GET /products(.:format) products#index - product GET /products/:id(.:format) products#show + Prefix Verb URI Pattern Controller#Action +products GET /products(.:format) products#index + product GET /products/:id(.:format) products#show ``` These route prefixes give us helpers like the following: From 743a75e1617d9dcac2464319752431091cb6d9c0 Mon Sep 17 00:00:00 2001 From: shravan097 Date: Wed, 13 Aug 2025 08:45:51 -0700 Subject: [PATCH 0451/1075] docs: document alias_attribute behavior change in Rails 7.2 upgrading guide --- guides/source/upgrading_ruby_on_rails.md | 54 ++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/guides/source/upgrading_ruby_on_rails.md b/guides/source/upgrading_ruby_on_rails.md index a2aef9652993f..1577d73495eba 100644 --- a/guides/source/upgrading_ruby_on_rails.md +++ b/guides/source/upgrading_ruby_on_rails.md @@ -104,6 +104,60 @@ set the `queue_adapter` config to something other than `:test`, but written test If no config is provided, the `TestAdapter` will continue to be used. +### `alias_attribute` now bypasses custom methods on the original attribute + +In Rails 7.2, `alias_attribute` now bypasses custom methods defined on the original attribute and directly accesses the underlying database value. This change was announced via deprecation warnings in Rails 7.1. + +**Before (Rails 7.1):** + +```ruby +class User < ActiveRecord::Base + def email + "custom_#{super}" + end + + alias_attribute :username, :email +end + +user = User.create!(email: "test@example.com") +user.username +# => "custom_test@example.com" +``` + +**After (Rails 7.2):** + +```ruby +user = User.create!(email: "test@example.com") +user.username +# => "test@example.com" # Raw database value +``` + +If you received the deprecation warning "Since Rails 7.2 `#{method_name}` will not be calling `#{target_name}` anymore", you should manually define the alias method: + +```ruby +class User < ActiveRecord::Base + def email + "custom_#{super}" + end + + def username + email # This will call the custom email method + end +end +``` + +Alternatively, you can use `alias_method`: + +```ruby +class User < ActiveRecord::Base + def email + "custom_#{super}" + end + + alias_method :username, :email +end +``` + Upgrading from Rails 7.0 to Rails 7.1 ------------------------------------- From 071670b2697a21f3eae629a679f9b45b02ac1072 Mon Sep 17 00:00:00 2001 From: Adrianna Chang Date: Fri, 4 Jul 2025 16:06:28 -0400 Subject: [PATCH 0452/1075] Add Structured Event Reporter Ref: https://github.com/rails/rails/issues/50452 This adds a Structured Event Reporter to Rails, accessible via `Rails.event`. It allows you to report events to a subscriber, and provides mechanisms for adding tags and context to events. Events encompass "structured logs", but also "business events", as well as telemetry events such as metrics and logs. The Event Reporter is designed to be a single interface for producing any kind of event in a Rails application. We separate the emission of events from how these events reach end consumers; applications are expected to define their own subscribers, and the Event Reporter is responsible for emitting events to these subscribers. --- activesupport/CHANGELOG.md | 41 ++ activesupport/lib/active_support.rb | 4 + .../lib/active_support/event_reporter.rb | 540 +++++++++++++++ .../active_support/event_reporter/encoders.rb | 90 +++ .../event_reporter/test_helper.rb | 32 + activesupport/lib/active_support/railtie.rb | 10 + activesupport/lib/active_support/test_case.rb | 2 + .../testing/event_reporter_assertions.rb | 163 +++++ activesupport/test/event_reporter_test.rb | 652 ++++++++++++++++++ .../testing/event_reporter_assertions_test.rb | 125 ++++ guides/source/configuring.md | 31 + railties/lib/rails.rb | 8 + railties/lib/rails/application/bootstrap.rb | 4 + 13 files changed, 1702 insertions(+) create mode 100644 activesupport/lib/active_support/event_reporter.rb create mode 100644 activesupport/lib/active_support/event_reporter/encoders.rb create mode 100644 activesupport/lib/active_support/event_reporter/test_helper.rb create mode 100644 activesupport/lib/active_support/testing/event_reporter_assertions.rb create mode 100644 activesupport/test/event_reporter_test.rb create mode 100644 activesupport/test/testing/event_reporter_assertions_test.rb diff --git a/activesupport/CHANGELOG.md b/activesupport/CHANGELOG.md index 37e98d063de5e..2d88f392eefc0 100644 --- a/activesupport/CHANGELOG.md +++ b/activesupport/CHANGELOG.md @@ -1,3 +1,44 @@ +* Add Structured Event Reporter, accessible via `Rails.event`. + + The Event Reporter provides a unified interface for producing structured events in Rails + applications: + + ```ruby + Rails.event.notify("user.signup", user_id: 123, email: "user@example.com") + ``` + + It supports adding tags to events: + + ```ruby + Rails.event.tagged("graphql") do + # Event includes tags: { graphql: true } + Rails.event.notify("user.signup", user_id: 123, email: "user@example.com") + end + ``` + + As well as context: + ```ruby + # All events will contain context: {request_id: "abc123", shop_id: 456} + Rails.event.set_context(request_id: "abc123", shop_id: 456) + ``` + + Events are emitted to subscribers. Applications register subscribers to + control how events are serialized and emitted. Rails provides several default + encoders that can be used to serialize events to common formats: + + ```ruby + class MySubscriber + def emit(event) + encoded_event = ActiveSupport::EventReporter.encoder(:json).encode(event) + StructuredLogExporter.export(encoded_event) + end + end + + Rails.event.subscribe(MySubscriber.new) + ``` + + *Adrianna Chang* + * Make `ActiveSupport::Logger` `#freeze`-friendly. *Joshua Young* diff --git a/activesupport/lib/active_support.rb b/activesupport/lib/active_support.rb index c62972ec52e2f..5a2810c7928e9 100644 --- a/activesupport/lib/active_support.rb +++ b/activesupport/lib/active_support.rb @@ -48,6 +48,7 @@ module ActiveSupport autoload :ExecutionWrapper autoload :Executor autoload :ErrorReporter + autoload :EventReporter autoload :FileUpdateChecker autoload :EventedFileUpdateChecker autoload :ForkTracker @@ -110,6 +111,9 @@ def self.eager_load! @error_reporter = ActiveSupport::ErrorReporter.new singleton_class.attr_accessor :error_reporter # :nodoc: + @event_reporter = ActiveSupport::EventReporter.new + singleton_class.attr_accessor :event_reporter # :nodoc: + def self.cache_format_version Cache.format_version end diff --git a/activesupport/lib/active_support/event_reporter.rb b/activesupport/lib/active_support/event_reporter.rb new file mode 100644 index 0000000000000..c78c21005802e --- /dev/null +++ b/activesupport/lib/active_support/event_reporter.rb @@ -0,0 +1,540 @@ +# typed: true +# frozen_string_literal: true + +require_relative "event_reporter/encoders" + +module ActiveSupport + class TagStack # :nodoc: + EMPTY_TAGS = {}.freeze + FIBER_KEY = :event_reporter_tags + + class << self + def tags + Fiber[FIBER_KEY] || EMPTY_TAGS + end + + def with_tags(*args, **kwargs) + existing_tags = tags + tags = existing_tags.dup + tags.merge!(resolve_tags(args, kwargs)) + new_tags = tags.freeze + + begin + Fiber[FIBER_KEY] = new_tags + yield + ensure + Fiber[FIBER_KEY] = existing_tags + end + end + + private + def resolve_tags(args, kwargs) + tags = args.each_with_object({}) do |arg, tags| + case arg + when String + tags[arg.to_sym] = true + when Symbol + tags[arg] = true + when Hash + arg.each { |key, value| tags[key.to_sym] = value } + else + tags[arg.class.name.to_sym] = arg + end + end + kwargs.each { |key, value| tags[key.to_sym] = value } + tags + end + end + end + + class EventContext # :nodoc: + EMPTY_CONTEXT = {}.freeze + FIBER_KEY = :event_reporter_context + + class << self + def context + Fiber[FIBER_KEY] || EMPTY_CONTEXT + end + + def set_context(context_hash) + new_context = self.context.dup + context_hash.each { |key, value| new_context[key.to_sym] = value } + + Fiber[FIBER_KEY] = new_context.freeze + end + + def clear + Fiber[FIBER_KEY] = EMPTY_CONTEXT + end + end + end + + # = Active Support \Event Reporter + # + # +ActiveSupport::EventReporter+ provides an interface for reporting structured events to subscribers. + # + # To report an event, you can use the +notify+ method: + # + # Rails.event.notify("user_created", { id: 123 }) + # # Emits event: + # # { + # # name: "user_created", + # # payload: { id: 123 }, + # # timestamp: 1738964843208679035, + # # source_location: { filepath: "path/to/file.rb", lineno: 123, label: "UserService#create" } + # # } + # + # ==== Filtered Subscriptions + # + # Subscribers can be configured with an optional filter proc to only receive a subset of events: + # + # # Only receive events with names starting with "user." + # Rails.event.subscribe(user_subscriber) { |event| event[:name].start_with?("user.") } + # + # # Only receive events with specific payload types + # Rails.event.subscribe(audit_subscriber) { |event| event[:payload].is_a?(AuditEvent) } + # + # The +notify+ API can receive either an event name and a payload hash, or an event object. Names are coerced to strings. + # + # ==== Event Objects + # + # If an event object is passed to the +notify+ API, it will be passed through to subscribers as-is, and the name of the + # object's class will be used as the event name. + # + # class UserCreatedEvent + # def initialize(id:, name:) + # @id = id + # @name = name + # end + # + # def to_h + # { + # id: @id, + # name: @name + # } + # end + # end + # + # Rails.event.notify(UserCreatedEvent.new(id: 123, name: "John Doe")) + # # Emits event: + # # { + # # name: "UserCreatedEvent", + # # payload: #, + # # timestamp: 1738964843208679035, + # # source_location: { filepath: "path/to/file.rb", lineno: 123, label: "UserService#create" } + # # } + # + # An event is any Ruby object representing a schematized event. While payload hashes allow arbitrary, + # implicitly-structured data, event objects are intended to enforce a particular schema. + # + # ==== Default Encoders + # + # Rails provides default encoders for common serialization formats. Event objects and tags MUST + # implement +to_h+ to be serialized. + # + # class JSONLogSubscriber + # def emit(event) + # # event = { name: "UserCreatedEvent", payload: { UserCreatedEvent: # } } + # json_data = ActiveSupport::EventReporter.encoder(:json).encode(event) + # # => { + # # "name": "UserCreatedEvent", + # # "payload": { + # # "id": 123, + # # "name": "John Doe" + # # } + # # } + # Rails.logger.info(json_data) + # end + # end + # + # class MessagePackSubscriber + # def emit(event) + # msgpack_data = ActiveSupport::EventReporter.encoder(:msgpack).encode(event) + # BatchExporter.export(msgpack_data) + # end + # end + # + # ==== Debug Events + # + # You can use the +debug+ method to report an event that will only be reported if the + # event reporter is in debug mode: + # + # Rails.event.debug("my_debug_event", { foo: "bar" }) + # + # ==== Tags + # + # To add additional context to an event, separate from the event payload, you can add + # tags via the +tagged+ method: + # + # Rails.event.tagged("graphql") do + # Rails.event.notify("user_created", { id: 123 }) + # end + # + # # Emits event: + # # { + # # name: "user_created", + # # payload: { id: 123 }, + # # tags: { graphql: true }, + # # timestamp: 1738964843208679035, + # # source_location: { filepath: "path/to/file.rb", lineno: 123, label: "UserService#create" } + # # } + # + # ==== Context Store + # + # You may want to attach metadata to every event emitted by the reporter. While tags + # provide domain-specific context for a series of events, context is scoped to the job / request + # and should be used for metadata associated with the execution context. + # Context can be set via the +set_context+ method: + # + # Rails.event.set_context(request_id: "abcd123", user_agent: "TestAgent") + # Rails.event.notify("user_created", { id: 123 }) + # + # # Emits event: + # # { + # # name: "user_created", + # # payload: { id: 123 }, + # # context: { request_id: "abcd123", user_agent: TestAgent" }, + # # timestamp: 1738964843208679035, + # # source_location: { filepath: "path/to/file.rb", lineno: 123, label: "UserService#create" } + # # } + # + # Context is reset automatically before and after each request. + # + # A custom context store can be configured via +config.active_support.event_reporter_context_store+. + # + # # config/application.rb + # config.active_support.event_reporter_context_store = CustomContextStore + # + # class CustomContextStore + # class << self + # def context + # # Return the context. + # end + # + # def set_context(context_hash) + # # Append context_hash to the existing context store. + # end + # + # def clear + # # Delete the stored context. + # end + # end + # end + # + # The Event Reporter standardizes on symbol keys for all payload data, tags, and context store entries. + # String keys are automatically converted to symbols for consistency. + # + # Rails.event.notify("user.created", { "id" => 123 }) + # # Emits event: + # # { + # # name: "user.created", + # # payload: { id: 123 }, + # # } + class EventReporter + attr_reader :subscribers + attr_accessor :raise_on_error + + ENCODERS = { + json: Encoders::JSON, + msgpack: Encoders::MessagePack + }.freeze + + class << self + attr_accessor :context_store # :nodoc: + + # Lookup an encoder by name or symbol. + # + # ActiveSupport::EventReporter.encoder(:json) + # # => ActiveSupport::EventReporter::Encoders::JSON + # + # ActiveSupport::EventReporter.encoder("msgpack") + # # => ActiveSupport::EventReporter::Encoders::MessagePack + # + # ==== Arguments + # + # * +format+ - The encoder format as a symbol or string + # + # ==== Raises + # + # * +KeyError+ - If the encoder format is not found + def encoder(format) + ENCODERS.fetch(format.to_sym) do + raise KeyError, "Unknown encoder format: #{format.inspect}. Available formats: #{ENCODERS.keys.join(', ')}" + end + end + end + + self.context_store = EventContext + + def initialize(*subscribers, raise_on_error: false, tags: nil) + @subscribers = [] + subscribers.each { |subscriber| subscribe(subscriber) } + @raise_on_error = raise_on_error + end + + # Registers a new event subscriber. The subscriber must respond to + # + # emit(event: Hash) + # + # The event hash will have the following keys: + # + # name: String (The name of the event) + # payload: Hash, Object (The payload of the event, or the event object itself) + # tags: Hash (The tags of the event) + # timestamp: Float (The timestamp of the event, in nanoseconds) + # source_location: Hash (The source location of the event, containing the filepath, lineno, and label) + # + # An optional filter proc can be provided to only receive a subset of events: + # + # Rails.event.subscribe(subscriber) { |event| event[:name].start_with?("user.") } + # Rails.event.subscribe(subscriber) { |event| event[:payload].is_a?(UserEvent) } + # + def subscribe(subscriber, &filter) + unless subscriber.respond_to?(:emit) + raise ArgumentError, "Event subscriber #{subscriber.class.name} must respond to #emit" + end + + @subscribers << { subscriber: subscriber, filter: filter } + end + + # Reports an event to all registered subscribers. An event name and payload can be provided: + # + # Rails.event.notify("user.created", { id: 123 }) + # # Emits event: + # # { + # # name: "user.created", + # # payload: { id: 123 }, + # # tags: {}, + # # timestamp: 1738964843208679035, + # # source_location: { filepath: "path/to/file.rb", lineno: 123, label: "UserService#create" } + # # } + # + # Alternatively, an event object can be provided: + # + # Rails.event.notify(UserCreatedEvent.new(id: 123)) + # # Emits event: + # # { + # # name: "UserCreatedEvent", + # # payload: #, + # # tags: {}, + # # timestamp: 1738964843208679035, + # # source_location: { filepath: "path/to/file.rb", lineno: 123, label: "UserService#create" } + # # } + # + # ==== Arguments + # + # * +:payload+ - The event payload when using string/symbol event names. + # + # * +:caller_depth+ - The stack depth to use for source location (default: 1). + # + # * +:kwargs+ - Additional payload data when using string/symbol event names. + def notify(name_or_object, payload = nil, caller_depth: 1, **kwargs) + name = resolve_name(name_or_object) + payload = resolve_payload(name_or_object, payload, **kwargs) + + event = { + name: name, + payload: payload, + tags: TagStack.tags, + context: context_store.context, + timestamp: Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond), + } + + caller_location = caller_locations(caller_depth, 1)&.first + + if caller_location + source_location = { + filepath: caller_location.path, + lineno: caller_location.lineno, + label: caller_location.label, + } + event[:source_location] = source_location + end + + subscribers.each do |subscriber_entry| + subscriber = subscriber_entry[:subscriber] + filter = subscriber_entry[:filter] + + next if filter && !filter.call(event) + + subscriber.emit(event) + rescue => subscriber_error + if raise_on_error + raise + else + warn(<<~MESSAGE) + Event reporter subscriber #{subscriber.class.name} raised an error on #emit: #{subscriber_error.message} + #{subscriber_error.backtrace&.join("\n")} + MESSAGE + end + end + end + + # Temporarily enables debug mode for the duration of the block. + # Calls to +debug+ will only be reported if debug mode is enabled. + # + # Rails.event.with_debug do + # Rails.event.debug("sql.query", { sql: "SELECT * FROM users" }) + # end + def with_debug + prior = Fiber[:event_reporter_debug_mode] + Fiber[:event_reporter_debug_mode] = true + yield + ensure + Fiber[:event_reporter_debug_mode] = prior + end + + # Check if debug mode is currently enabled. + def debug_mode? + Fiber[:event_reporter_debug_mode] + end + + # Report an event only when in debug mode. For example: + # + # Rails.event.debug("sql.query", { sql: "SELECT * FROM users" }) + # + # ==== Arguments + # + # * +:payload+ - The event payload when using string/symbol event names. + # + # * +:caller_depth+ - The stack depth to use for source location (default: 1). + # + # * +:kwargs+ - Additional payload data when using string/symbol event names. + def debug(name_or_object, payload = nil, caller_depth: 1, **kwargs) + if debug_mode? + if block_given? + notify(name_or_object, payload, caller_depth: caller_depth + 1, **kwargs.merge(yield)) + else + notify(name_or_object, payload, caller_depth: caller_depth + 1, **kwargs) + end + end + end + + # Add tags to events to supply additional context. Tags operate in a stack-oriented manner, + # so all events emitted within the block inherit the same set of tags. For example: + # + # Rails.event.tagged("graphql") do + # Rails.event.notify("user.created", { id: 123 }) + # end + # + # # Emits event: + # # { + # # name: "user.created", + # # payload: { id: 123 }, + # # tags: { graphql: true }, + # # timestamp: 1738964843208679035, + # # source_location: { filepath: "path/to/file.rb", lineno: 123, label: "UserService#create" } + # # } + # + # Tags can be provided as arguments or as keyword arguments, and can be nested: + # + # Rails.event.tagged("graphql") do + # # Other code here... + # Rails.event.tagged(section: "admin") do + # Rails.event.notify("user.created", { id: 123 }) + # end + # end + # + # # Emits event: + # # { + # # name: "user.created", + # # payload: { id: 123 }, + # # tags: { section: "admin", graphql: true }, + # # timestamp: 1738964843208679035, + # # source_location: { filepath: "path/to/file.rb", lineno: 123, label: "UserService#create" } + # # } + # + # The +tagged+ API can also receive a tag object: + # + # graphql_tag = GraphqlTag.new(operation_name: "user_created", operation_type: "mutation") + # Rails.event.tagged(graphql_tag) do + # Rails.event.notify("user.created", { id: 123 }) + # end + # + # # Emits event: + # # { + # # name: "user.created", + # # payload: { id: 123 }, + # # tags: { "GraphqlTag": # }, + # # timestamp: 1738964843208679035, + # # source_location: { filepath: "path/to/file.rb", lineno: 123, label: "UserService#create" } + # # } + def tagged(*args, **kwargs, &block) + TagStack.with_tags(*args, **kwargs, &block) + end + + # Sets context data that will be included with all events emitted by the reporter. + # Context data should be scoped to the job or request, and is reset automatically + # before and after each request and job. + # + # Rails.event.set_context(user_agent: "TestAgent") + # Rails.event.set_context(job_id: "abc123") + # Rails.event.tagged("graphql") do + # Rails.event.notify("user_created", { id: 123 }) + # end + # + # # Emits event: + # # { + # # name: "user_created", + # # payload: { id: 123 }, + # # tags: { graphql: true }, + # # context: { user_agent: "TestAgent", job_id: "abc123" }, + # # timestamp: 1738964843208679035 + # # } + def set_context(context) + context_store.set_context(context) + end + + # Clears all context data. + def clear_context + context_store.clear + end + + # Returns the current context data. + def context + context_store.context + end + + private + def context_store + self.class.context_store + end + + def resolve_name(name_or_object) + case name_or_object + when String, Symbol + name_or_object.to_s + else + name_or_object.class.name + end + end + + def resolve_payload(name_or_object, payload, **kwargs) + case name_or_object + when String, Symbol + handle_unexpected_args(name_or_object, payload, kwargs) if payload && kwargs.any? + if kwargs.any? + kwargs.transform_keys(&:to_sym) + elsif payload + payload.transform_keys(&:to_sym) + end + else + handle_unexpected_args(name_or_object, payload, kwargs) if payload || kwargs.any? + name_or_object + end + end + + def handle_unexpected_args(name_or_object, payload, kwargs) + message = <<~MESSAGE + Rails.event.notify accepts either an event object, a payload hash, or keyword arguments. + Received: #{name_or_object.inspect}, #{payload.inspect}, #{kwargs.inspect} + MESSAGE + + if raise_on_error + raise ArgumentError, message + else + warn(message) + end + end + end +end diff --git a/activesupport/lib/active_support/event_reporter/encoders.rb b/activesupport/lib/active_support/event_reporter/encoders.rb new file mode 100644 index 0000000000000..d0eb44d6b4a47 --- /dev/null +++ b/activesupport/lib/active_support/event_reporter/encoders.rb @@ -0,0 +1,90 @@ +# typed: true +# frozen_string_literal: true + +module ActiveSupport + class EventReporter + # = Event Encoders + # + # Default encoders for serializing structured events. These encoders can be used + # by subscribers to convert event data into various formats. + # + # Example usage in a subscriber: + # + # class LogSubscriber + # def emit(event) + # encoded_data = ActiveSupport::EventReporter::Encoders::JSON.encode(event) + # Rails.logger.info(encoded_data) + # end + # end + # + # Rails.event.subscribe(LogSubscriber) + module Encoders + # Base encoder class that other encoders can inherit from. + class Base + # Encodes an event hash into a serialized format. + # + # @param event [Hash] The event hash containing name, payload, tags, context, timestamp, and source_location + # @return [String] The encoded event data + def self.encode(event) + raise NotImplementedError, "Subclasses must implement #encode" + end + end + + # JSON encoder for serializing events to JSON format. + # + # event = { name: "user_created", payload: { id: 123 }, tags: { api: true } } + # ActiveSupport::EventReporter::Encoders::JSON.encode(event) + # # => { + # # "name": "user_created", + # # "payload": { + # # "id": 123 + # # }, + # # "tags": { + # # "api": true + # # }, + # # "context": {} + # # } + # + # Schematized events and tags MUST respond to #to_h to be serialized. + # + # event = { name: "UserCreatedEvent", payload: #, tags: { "GraphqlTag": # } } + # ActiveSupport::EventReporter::Encoders::JSON.encode(event) + # # => { + # # "name": "UserCreatedEvent", + # # "payload": { + # # "id": 123 + # # }, + # # "tags": { + # # "GraphqlTag": { + # # "operation_name": "user_created", + # # "operation_type": "mutation" + # # } + # # }, + # # "context": {} + # # } + class JSON < Base + def self.encode(event) + event[:payload] = event[:payload].to_h + event[:tags] = event[:tags].transform_values do |value| + value.respond_to?(:to_h) ? value.to_h : value + end + ::JSON.dump(event) + end + end + + # EventReporter encoder for serializing events to MessagePack format. + class MessagePack < Base + def self.encode(event) + require "msgpack" + event[:payload] = event[:payload].to_h + event[:tags] = event[:tags].transform_values do |value| + value.respond_to?(:to_h) ? value.to_h : value + end + ::MessagePack.pack(event) + rescue LoadError + raise LoadError, "msgpack gem is required for MessagePack encoding. Add 'gem \"msgpack\"' to your Gemfile." + end + end + end + end +end diff --git a/activesupport/lib/active_support/event_reporter/test_helper.rb b/activesupport/lib/active_support/event_reporter/test_helper.rb new file mode 100644 index 0000000000000..8d317f26c9384 --- /dev/null +++ b/activesupport/lib/active_support/event_reporter/test_helper.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module ActiveSupport::EventReporter::TestHelper # :nodoc: + class EventSubscriber # :nodoc: + attr_reader :events + + def initialize + @events = [] + end + + def emit(event) + @events << event + end + end + + def event_matcher(name:, payload: nil, tags: {}, context: {}, source_location: nil) + ->(event) { + return false unless event[:name] == name + return false unless event[:payload] == payload + return false unless event[:tags] == tags + return false unless event[:context] == context + + [:filepath, :lineno, :label].each do |key| + if source_location && source_location[key] + return false unless event[:source_location][key] == source_location[key] + end + end + + true + } + end +end diff --git a/activesupport/lib/active_support/railtie.rb b/activesupport/lib/active_support/railtie.rb index fd231c2be5b51..4acf2766e4a84 100644 --- a/activesupport/lib/active_support/railtie.rb +++ b/activesupport/lib/active_support/railtie.rb @@ -38,10 +38,19 @@ class Railtie < Rails::Railtie # :nodoc: end end + initializer "active_support.set_event_reporter_context_store" do |app| + config.after_initialize do + if klass = app.config.active_support.event_reporter_context_store + ActiveSupport.event_reporter.context_store = klass + end + end + end + initializer "active_support.reset_execution_context" do |app| app.reloader.before_class_unload do ActiveSupport::CurrentAttributes.clear_all ActiveSupport::ExecutionContext.clear + ActiveSupport.event_reporter.clear_context end app.executor.to_run do @@ -51,6 +60,7 @@ class Railtie < Rails::Railtie # :nodoc: app.executor.to_complete do ActiveSupport::CurrentAttributes.clear_all ActiveSupport::ExecutionContext.pop + ActiveSupport.event_reporter.clear_context end ActiveSupport.on_load(:active_support_test_case) do diff --git a/activesupport/lib/active_support/test_case.rb b/activesupport/lib/active_support/test_case.rb index 130685a1313e6..d6e26d3b584d2 100644 --- a/activesupport/lib/active_support/test_case.rb +++ b/activesupport/lib/active_support/test_case.rb @@ -6,6 +6,7 @@ require "active_support/testing/tests_without_assertions" require "active_support/testing/assertions" require "active_support/testing/error_reporter_assertions" +require "active_support/testing/event_reporter_assertions" require "active_support/testing/deprecation" require "active_support/testing/declarative" require "active_support/testing/isolation" @@ -178,6 +179,7 @@ def parallelize_teardown(&block) prepend ActiveSupport::Testing::TestsWithoutAssertions include ActiveSupport::Testing::Assertions include ActiveSupport::Testing::ErrorReporterAssertions + include ActiveSupport::Testing::EventReporterAssertions include ActiveSupport::Testing::NotificationAssertions include ActiveSupport::Testing::Deprecation include ActiveSupport::Testing::ConstantStubbing diff --git a/activesupport/lib/active_support/testing/event_reporter_assertions.rb b/activesupport/lib/active_support/testing/event_reporter_assertions.rb new file mode 100644 index 0000000000000..6ad23d6ff7bd2 --- /dev/null +++ b/activesupport/lib/active_support/testing/event_reporter_assertions.rb @@ -0,0 +1,163 @@ +# typed: true +# frozen_string_literal: true + +module ActiveSupport + module Testing + # Provides test helpers for asserting on ActiveSupport::EventReporter events. + module EventReporterAssertions + module EventCollector # :nodoc: + @subscribed = false + @mutex = Mutex.new + + class Event # :nodoc: + attr_reader :event_data + + def initialize(event_data) + @event_data = event_data + end + + def inspect + "#{event_data[:name]} (payload: #{event_data[:payload].inspect}, tags: #{event_data[:tags].inspect})" + end + + def matches?(name, payload, tags) + return false unless name.to_s == event_data[:name] + + if payload && payload.is_a?(Hash) + return false unless matches_hash?(payload, :payload) + end + + return false unless matches_hash?(tags, :tags) + true + end + + private + def matches_hash?(expected_hash, event_key) + expected_hash.all? do |k, v| + if v.is_a?(Regexp) + event_data.dig(event_key, k).to_s.match?(v) + else + event_data.dig(event_key, k) == v + end + end + end + end + + class << self + def emit(event) + event_recorders&.each do |events| + events << Event.new(event) + end + true + end + + def record + subscribe + events = [] + event_recorders << events + begin + yield + events + ensure + event_recorders.delete_if { |r| events.equal?(r) } + end + end + + private + def subscribe + return if @subscribed + + @mutex.synchronize do + unless @subscribed + if ActiveSupport.event_reporter + ActiveSupport.event_reporter.subscribe(self) + @subscribed = true + else + raise Minitest::Assertion, "No event reporter is configured" + end + end + end + end + + def event_recorders + ActiveSupport::IsolatedExecutionState[:active_support_event_reporter_assertions] ||= [] + end + end + end + + # Asserts that the block does not cause an event to be reported to +Rails.event+. + # + # If no name is provided, passes if evaluated code in the yielded block reports no events. + # + # assert_no_event_reported do + # service_that_does_not_report_events.perform + # end + # + # If a name is provided, passes if evaluated code in the yielded block reports no events + # with that name. + # + # assert_no_event_reported("user.created") do + # service_that_does_not_report_events.perform + # end + def assert_no_event_reported(name = nil, payload: {}, tags: {}, &block) + events = EventCollector.record(&block) + + if name.nil? + assert_predicate(events, :empty?) + else + matching_event = events.find { |event| event.matches?(name, payload, tags) } + if matching_event + message = "Expected no '#{name}' event to be reported, but found:\n " \ + "#{matching_event.inspect}" + flunk(message) + end + assert(true) + end + end + + # Asserts that the block causes an event with the given name to be reported + # to +Rails.event+. + # + # Passes if the evaluated code in the yielded block reports a matching event. + # + # assert_event_reported("user.created") do + # Rails.event.notify("user.created", { id: 123 }) + # end + # + # To test further details about the reported event, you can specify payload and tag matchers. + # + # assert_event_reported("user.created", + # payload: { id: 123, name: "John Doe" }, + # tags: { request_id: /[0-9]+/ } + # ) do + # Rails.event.tagged(request_id: "123") do + # Rails.event.notify("user.created", { id: 123, name: "John Doe" }) + # end + # end + # + # The matchers support partial matching - only the specified keys need to match. + # + # assert_event_reported("user.created", payload: { id: 123 }) do + # Rails.event.notify("user.created", { id: 123, name: "John Doe" }) + # end + def assert_event_reported(name, payload: nil, tags: {}, &block) + events = EventCollector.record(&block) + + if events.empty? + flunk("Expected an event to be reported, but there were no events reported.") + elsif (event = events.find { |event| event.matches?(name, payload, tags) }) + assert(true) + event.event_data + else + message = "Expected an event to be reported matching:\n " \ + "name: #{name}\n " \ + "payload: #{payload.inspect}\n " \ + "tags: #{tags.inspect}\n" \ + "but none of the #{events.size} reported events matched:\n " \ + "#{events.map(&:inspect).join("\n ")}" + flunk(message) + end + end + end + end +end diff --git a/activesupport/test/event_reporter_test.rb b/activesupport/test/event_reporter_test.rb new file mode 100644 index 0000000000000..1a4cbe8640558 --- /dev/null +++ b/activesupport/test/event_reporter_test.rb @@ -0,0 +1,652 @@ +# typed: true +# frozen_string_literal: true + +require_relative "abstract_unit" +require "active_support/event_reporter/test_helper" +require "json" + +module ActiveSupport + class EventReporterTest < ActiveSupport::TestCase + include EventReporter::TestHelper + + setup do + @subscriber = EventReporter::TestHelper::EventSubscriber.new + @reporter = EventReporter.new(@subscriber, raise_on_error: true) + end + + class TestEvent + def initialize(data) + @data = data + end + end + + class HttpRequestTag + def initialize(http_method, http_status) + @http_method = http_method + @http_status = http_status + end + end + + class LoggingAbstraction + def initialize(reporter) + @reporter = reporter + end + + def a_log_method(message) + @reporter.notify(:custom_event, caller_depth: 2, message: message) + end + + def a_debug_method(message) + @reporter.debug(:custom_event, caller_depth: 2, message: message) + end + end + + class ErrorSubscriber + def emit(event) + raise StandardError.new("Uh oh!") + end + end + + test "#subscribe" do + reporter = ActiveSupport::EventReporter.new + reporter.subscribe(@subscriber) + assert_equal([{ subscriber: @subscriber, filter: nil }], reporter.subscribers) + end + + test "#subscribe with filter" do + reporter = ActiveSupport::EventReporter.new + + filter = ->(event) { event[:name].start_with?("user.") } + reporter.subscribe(@subscriber, &filter) + + assert_equal([{ subscriber: @subscriber, filter: filter }], reporter.subscribers) + end + + test "#subscribe raises ArgumentError when sink doesn't respond to emit" do + invalid_subscriber = Object.new + + error = assert_raises(ArgumentError) do + @reporter.subscribe(invalid_subscriber) + end + + assert_equal "Event subscriber Object must respond to #emit", error.message + end + + test "#notify with name" do + assert_called_with(@subscriber, :emit, [ + event_matcher(name: "test_event") + ]) do + @reporter.notify(:test_event) + end + end + + test "#notify filters" do + reporter = ActiveSupport::EventReporter.new + reporter.subscribe(@subscriber) { |event| event[:name].start_with?("user_") } + + assert_not_called(@subscriber, :emit) do + reporter.notify(:test_event) + end + + assert_called_with(@subscriber, :emit, [ + event_matcher(name: "user_event") + ]) do + reporter.notify(:user_event) + end + end + + test "#notify with name and hash payload" do + assert_called_with(@subscriber, :emit, [ + event_matcher(name: "test_event", payload: { key: "value" }) + ]) do + @reporter.notify(:test_event, { key: "value" }) + end + end + + test "#notify with name and kwargs" do + assert_called_with(@subscriber, :emit, [ + event_matcher(name: "test_event", payload: { key: "value" }) + ]) do + @reporter.notify(:test_event, key: "value") + end + end + + test "#notify symbolizes keys in hash payload" do + assert_called_with(@subscriber, :emit, [ + event_matcher(name: "test_event", payload: { key: "value" }) + ]) do + @reporter.notify(:test_event, { "key" => "value" }) + end + end + + test "#notify with hash payload and kwargs raises" do + error = assert_raises(ArgumentError) do + @reporter.notify(:test_event, { key: "value" }, extra: "arg") + end + + assert_match( + /Rails.event.notify accepts either an event object, a payload hash, or keyword arguments/, + error.message + ) + end + + test "#notify includes source location in event payload" do + filepath = __FILE__ + lineno = __LINE__ + 4 + assert_called_with(@subscriber, :emit, [ + event_matcher(name: "test_event", source_location: { filepath:, lineno: }) + ]) do + @reporter.notify("test_event") + end + end + + test "#notify with caller depth option" do + logging_abstraction = LoggingAbstraction.new(@reporter) + filepath = __FILE__ + lineno = __LINE__ + 4 + assert_called_with(@subscriber, :emit, [ + event_matcher(name: "custom_event", payload: { message: "hello" }, source_location: { filepath:, lineno: }) + ]) do + logging_abstraction.a_log_method("hello") + end + end + + test "#notify with event object" do + event = TestEvent.new("value") + + assert_called_with(@subscriber, :emit, [ + event_matcher(name: TestEvent.name, payload: event) + ]) do + @reporter.notify(event) + end + end + + test "#notify with event object and kwargs raises when raise_on_error is true" do + event = TestEvent.new("value") + error = assert_raises(ArgumentError) do + @reporter.notify(event, extra: "arg") + end + + assert_match( + /Rails.event.notify accepts either an event object, a payload hash, or keyword arguments/, + error.message + ) + end + + test "#notify with event object and hash payload raises when raise_on_error is true" do + event = TestEvent.new("value") + error = assert_raises(ArgumentError) do + @reporter.notify(event, { extra: "arg" }) + rescue RailsStrictWarnings::WarningError => _e + # Expected warning + end + + assert_match( + /Rails.event.notify accepts either an event object, a payload hash, or keyword arguments/, + error.message + ) + end + + test "#notify with event object and kwargs warns when raise_on_error is false" do + previous_raise_on_error = @reporter.raise_on_error + @reporter.raise_on_error = false + + event = TestEvent.new("value") + + _out, err = capture_io do + assert_called_with(@subscriber, :emit, [ + event_matcher(name: TestEvent.name, payload: event) + ]) do + @reporter.notify(event, extra: "arg") + rescue RailsStrictWarnings::WarningError => _e + # Expected warning + end + end + + assert_match(/Rails.event.notify accepts either an event object, a payload hash, or keyword arguments/, err) + ensure + @reporter.raise_on_error = previous_raise_on_error + end + + test "#notify warns about subscriber errors when raise_on_error is false" do + previous_raise_on_error = @reporter.raise_on_error + @reporter.raise_on_error = false + + @reporter.subscribe(ErrorSubscriber.new) + + _out, err = capture_io do + @reporter.notify(:test_event) + rescue RailsStrictWarnings::WarningError => _e + # Expected warning + end + + assert_match(/Event reporter subscriber #{ErrorSubscriber.name} raised an error on #emit: Uh oh!/, err) + ensure + @reporter.raise_on_error = previous_raise_on_error + end + + test "#notify raises subscriber errors when raise_on_error is true" do + @reporter.subscribe(ErrorSubscriber.new) + + error = assert_raises(StandardError) do + @reporter.notify(:test_event) + end + + assert_equal("Uh oh!", error.message) + end + + test "#with_debug" do + @reporter.with_debug do + assert_predicate @reporter, :debug_mode? + end + assert_not_predicate @reporter, :debug_mode? + end + + test "#with_debug works with nested calls" do + @reporter.with_debug do + assert_predicate @reporter, :debug_mode? + + @reporter.with_debug do + assert_predicate @reporter, :debug_mode? + end + + assert_predicate @reporter, :debug_mode? + end + end + + test "#debug emits when in debug mode" do + @reporter.with_debug do + assert_called_with(@subscriber, :emit, [ + event_matcher(name: "test_event", payload: { key: "value" }) + ]) do + @reporter.debug(:test_event, key: "value") + end + end + end + + test "#debug with caller depth" do + logging_abstraction = LoggingAbstraction.new(@reporter) + filepath = __FILE__ + lineno = __LINE__ + 4 + assert_called_with(@subscriber, :emit, [ + event_matcher(name: "custom_event", payload: { message: "hello" }, source_location: { filepath:, lineno: }) + ]) do + @reporter.with_debug { logging_abstraction.a_debug_method("hello") } + end + end + + test "#debug emits in debug mode with block" do + @reporter.with_debug do + assert_called_with(@subscriber, :emit, [ + event_matcher(name: "test_event", payload: { slow_to_compute: "value" }) + ]) do + @reporter.debug(:test_event) do + { slow_to_compute: "value" } + end + end + end + end + + test "#debug does not emit when not in debug mode" do + assert_not_called(@subscriber, :emit) do + @reporter.debug(:test_event, key: "value") + end + end + + test "#debug with block merges kwargs" do + @reporter.with_debug do + assert_called_with(@subscriber, :emit, [ + event_matcher(name: "test_event", payload: { key: "value", slow_to_compute: "another_value" }) + ]) do + @reporter.debug(:test_event, key: "value") do + { slow_to_compute: "another_value" } + end + end + end + end + + test "#tagged adds tags to the emitted event" do + assert_called_with(@subscriber, :emit, [ + event_matcher(name: "test_event", payload: { key: "value" }, tags: { section: "admin" }) + ]) do + @reporter.tagged(section: "admin") do + @reporter.notify(:test_event, key: "value") + end + end + + assert_called_with(@subscriber, :emit, [ + event_matcher(name: "test_event", payload: { key: "value" }, tags: { section: "checkouts" }) + ]) do + @reporter.tagged({ section: "checkouts" }) do + @reporter.notify(:test_event, key: "value") + end + end + end + + test "#tagged with nested tags" do + @reporter.tagged(section: "admin") do + @reporter.tagged(nested: "tag") do + assert_called_with(@subscriber, :emit, [ + event_matcher(name: "test_event", payload: { key: "value" }, tags: { section: "admin", nested: "tag" }) + ]) do + @reporter.notify(:test_event, key: "value") + end + end + @reporter.tagged(hello: "world") do + assert_called_with(@subscriber, :emit, [ + event_matcher(name: "test_event", payload: { key: "value" }, tags: { section: "admin", hello: "world" }) + ]) do + @reporter.notify(:test_event, key: "value") + end + end + end + end + + test "#tagged with boolean tags" do + assert_called_with(@subscriber, :emit, [ + event_matcher(name: "test_event", payload: { key: "value" }, tags: { is_for_testing: true }) + ]) do + @reporter.tagged(:is_for_testing) do + @reporter.notify(:test_event, key: "value") + end + end + end + + test "#tagged can overwrite values on collision" do + assert_called_with(@subscriber, :emit, [ + event_matcher(name: "test_event", payload: { key: "value" }, tags: { section: "checkouts" }) + ]) do + @reporter.tagged(section: "admin") do + @reporter.tagged(section: "checkouts") do + @reporter.notify(:test_event, key: "value") + end + end + end + end + + test "#tagged with tag object" do + http_tag = HttpRequestTag.new("GET", 200) + + @reporter.tagged(http_tag) do + assert_called_with(@subscriber, :emit, [ + event_matcher(name: "test_event", payload: { key: "value" }, tags: { "#{HttpRequestTag.name}": http_tag }) + ]) do + @reporter.notify(:test_event, key: "value") + end + end + end + + test "#tagged with mixed tags" do + http_tag = HttpRequestTag.new("GET", 200) + @reporter.tagged("foobar", http_tag, shop_id: 123) do + assert_called_with(@subscriber, :emit, [ + event_matcher(name: "test_event", payload: { key: "value" }, tags: { foobar: true, "#{HttpRequestTag.name}": http_tag, shop_id: 123 }) + ]) do + @reporter.notify(:test_event, key: "value") + end + end + end + + test "#tagged copies tag stack from parent fiber without mutating parent's tag stack" do + @reporter.tagged(shop_id: 999) do + Fiber.new do + @reporter.tagged(shop_id: 123) do + assert_called_with(@subscriber, :emit, [ + event_matcher(name: "test_event", payload: { key: "value" }, tags: { shop_id: 123 }) + ]) do + @reporter.notify(:test_event, key: "value") + end + end + end.resume + + assert_called_with(@subscriber, :emit, [ + event_matcher(name: "parent_event", payload: { key: "parent" }, tags: { shop_id: 999 }) + ]) do + @reporter.notify(:parent_event, key: "parent") + end + end + end + + test "#tagged maintains isolation between concurrent fibers" do + @reporter.tagged(shop_id: 123) do + fiber = Fiber.new do + assert_called_with(@subscriber, :emit, [ + event_matcher(name: "child_event", payload: { key: "value" }, tags: { shop_id: 123 }) + ]) do + @reporter.notify(:child_event, key: "value") + end + end + + @reporter.tagged(api_client_id: 456) do + fiber.resume + + # Verify parent fiber has both tags + assert_called_with(@subscriber, :emit, [ + event_matcher(name: "parent_event", payload: { key: "parent" }, tags: { shop_id: 123, api_client_id: 456 }) + ]) do + @reporter.notify(:parent_event, key: "parent") + end + end + end + end + end + + class ContextStoreTest < ActiveSupport::TestCase + include EventReporter::TestHelper + + setup do + @subscriber = EventReporter::TestHelper::EventSubscriber.new + @reporter = EventReporter.new(@subscriber, raise_on_error: true) + end + + teardown do + EventContext.clear + end + + test "#context returns empty hash by default" do + assert_equal({}, @reporter.context) + end + + test "#set_context sets context data" do + @reporter.set_context(shop_id: 123) + assert_equal({ shop_id: 123 }, @reporter.context) + end + + test "#set_context merges with existing context" do + @reporter.set_context(shop_id: 123) + @reporter.set_context(user_id: 456) + assert_equal({ shop_id: 123, user_id: 456 }, @reporter.context) + end + + test "#set_context overwrites existing keys" do + @reporter.set_context(shop_id: 123) + @reporter.set_context(shop_id: 456) + assert_equal({ shop_id: 456 }, @reporter.context) + end + + test "#set_context with string keys converts them to symbols" do + @reporter.set_context("shop_id" => 123) + assert_equal({ shop_id: 123 }, @reporter.context) + end + + test "#clear_context removes all context data" do + @reporter.set_context(shop_id: 123, user_id: 456) + @reporter.clear_context + assert_equal({}, @reporter.context) + end + + test "#notify includes context in event" do + @reporter.set_context(shop_id: 123) + + assert_called_with(@subscriber, :emit, [ + event_matcher(name: "test_event", payload: { key: "value" }, tags: {}, context: { shop_id: 123 }) + ]) do + @reporter.notify(:test_event, key: "value") + end + end + + test "#context inherited by child fibers without mutating parent's context" do + @reporter.set_context(shop_id: 999) + Fiber.new do + @reporter.set_context(shop_id: 123) + assert_called_with(@subscriber, :emit, [ + event_matcher(name: "test_event", context: { shop_id: 123 }) + ]) do + @reporter.notify(:test_event) + end + end.resume + + assert_called_with(@subscriber, :emit, [ + event_matcher(name: "parent_event", payload: { key: "parent" }, context: { shop_id: 999 }) + ]) do + @reporter.notify(:parent_event, key: "parent") + end + end + + test "#context isolated between concurrent fibers" do + @reporter.set_context(shop_id: 123) + fiber = Fiber.new do + assert_called_with(@subscriber, :emit, [ + event_matcher(name: "child_event", context: { shop_id: 123 }) + ]) do + @reporter.notify(:child_event) + end + end + + @reporter.set_context(api_client_id: 456) + fiber.resume + + assert_called_with(@subscriber, :emit, [ + event_matcher(name: "parent_event", context: { shop_id: 123, api_client_id: 456 }) + ]) do + @reporter.notify(:parent_event) + end + end + + test "context is preserved when using #tagged" do + @reporter.set_context(shop_id: 123) + + @reporter.tagged(request_id: "abc") do + assert_equal({ shop_id: 123 }, @reporter.context) + + assert_called_with(@subscriber, :emit, [ + event_matcher(name: "test_event", payload: { key: "value" }, tags: { request_id: "abc" }, context: { shop_id: 123 }) + ]) do + @reporter.notify(:test_event, key: "value") + end + end + end + end + + class EncodersTest < ActiveSupport::TestCase + class TestEvent + def initialize(data) + @data = data + end + + def to_h + { data: @data } + end + end + + class HttpRequestTag + def initialize(http_method, http_status) + @http_method = http_method + @http_status = http_status + end + + def to_h + { + http_method: @http_method, + http_status: @http_status, + } + end + end + + setup do + @event = { + name: "test_event", + payload: { id: 123, message: "hello" }, + tags: { section: "admin" }, + context: { user_id: 456 }, + timestamp: 1738964843208679035, + source_location: { filepath: "/path/to/file.rb", lineno: 42, label: "test_method" } + } + end + + test "looking up encoder by symbol" do + assert_equal EventReporter::Encoders::JSON, EventReporter.encoder(:json) + assert_equal EventReporter::Encoders::MessagePack, EventReporter.encoder(:msgpack) + end + + test "looking up encoder by string" do + assert_equal EventReporter::Encoders::JSON, EventReporter.encoder("json") + assert_equal EventReporter::Encoders::MessagePack, EventReporter.encoder("msgpack") + end + + test "looking up nonexistant encoder raises KeyError" do + error = assert_raises(KeyError) do + EventReporter.encoder(:unknown) + end + assert_equal "Unknown encoder format: :unknown. Available formats: json, msgpack", error.message + end + + test "Base encoder raises NotImplementedError" do + assert_raises(NotImplementedError) do + EventReporter::Encoders::Base.encode(@event) + end + end + + test "JSON encoder encodes event to JSON" do + json_string = EventReporter::Encoders::JSON.encode(@event) + parsed = ::JSON.parse(json_string) + + assert_equal "test_event", parsed["name"] + assert_equal({ "id" => 123, "message" => "hello" }, parsed["payload"]) + assert_equal({ "section" => "admin" }, parsed["tags"]) + assert_equal({ "user_id" => 456 }, parsed["context"]) + assert_equal 1738964843208679035, parsed["timestamp"] + assert_equal({ "filepath" => "/path/to/file.rb", "lineno" => 42, "label" => "test_method" }, parsed["source_location"]) + end + + test "JSON encoder serializes event objects and object tags as hashes" do + @event[:payload] = TestEvent.new("value") + @event[:tags] = { "HttpRequestTag": HttpRequestTag.new("GET", 200) } + json_string = EventReporter::Encoders::JSON.encode(@event) + parsed = ::JSON.parse(json_string) + + assert_equal "value", parsed["payload"]["data"] + assert_equal "GET", parsed["tags"]["HttpRequestTag"]["http_method"] + assert_equal 200, parsed["tags"]["HttpRequestTag"]["http_status"] + end + + test "MessagePack encoder encodes event to MessagePack" do + begin + require "msgpack" + rescue LoadError + skip "msgpack gem not available" + end + + msgpack_data = EventReporter::Encoders::MessagePack.encode(@event) + parsed = ::MessagePack.unpack(msgpack_data) + + assert_equal "test_event", parsed["name"] + assert_equal({ "id" => 123, "message" => "hello" }, parsed["payload"]) + assert_equal({ "section" => "admin" }, parsed["tags"]) + assert_equal({ "user_id" => 456 }, parsed["context"]) + assert_equal 1738964843208679035, parsed["timestamp"] + assert_equal({ "filepath" => "/path/to/file.rb", "lineno" => 42, "label" => "test_method" }, parsed["source_location"]) + end + + test "MessagePack encoder serializes event objects and object tags as hashes" do + @event[:payload] = TestEvent.new("value") + @event[:tags] = { "HttpRequestTag": HttpRequestTag.new("GET", 200) } + msgpack_data = EventReporter::Encoders::MessagePack.encode(@event) + parsed = ::MessagePack.unpack(msgpack_data) + + assert_equal "value", parsed["payload"]["data"] + assert_equal "GET", parsed["tags"]["HttpRequestTag"]["http_method"] + assert_equal 200, parsed["tags"]["HttpRequestTag"]["http_status"] + end + end +end diff --git a/activesupport/test/testing/event_reporter_assertions_test.rb b/activesupport/test/testing/event_reporter_assertions_test.rb new file mode 100644 index 0000000000000..7d460a02fb6cd --- /dev/null +++ b/activesupport/test/testing/event_reporter_assertions_test.rb @@ -0,0 +1,125 @@ +# typed: true +# frozen_string_literal: true + +require "active_support/test_case" + +module ActiveSupport + module Testing + class EventReporterAssertionsTest < ActiveSupport::TestCase + setup do + @reporter = ActiveSupport.event_reporter + end + + test "#assert_event_reported" do + assert_event_reported("user.created") do + @reporter.notify("user.created", { id: 123, name: "John Doe" }) + end + end + + test "#assert_event_reported with payload" do + assert_event_reported("user.created", payload: { id: 123, name: "John Doe" }) do + @reporter.notify("user.created", { id: 123, name: "John Doe" }) + end + end + + test "#assert_event_reported with tags" do + assert_event_reported("user.created", tags: { graphql: true }) do + @reporter.tagged(:graphql) do + @reporter.notify("user.created", { id: 123, name: "John Doe" }) + end + end + end + + test "#assert_event_reported partial matching" do + assert_event_reported("user.created", payload: { id: 123 }, tags: { foo: :bar }) do + @reporter.tagged(foo: :bar, baz: :qux) do + @reporter.notify("user.created", { id: 123, name: "John Doe" }) + end + end + end + + test "#assert_event_reported with regex payload" do + assert_event_reported("user.created", payload: { id: /[0-9]+/ }) do + @reporter.notify("user.created", { id: 123, name: "John Doe" }) + end + end + + test "#assert_event_reported with regex tags" do + assert_event_reported("user.created", tags: { foo: /bar/ }) do + @reporter.tagged(foo: :bar, baz: :qux) do + @reporter.notify("user.created") + end + end + end + + test "#assert_no_event_reported" do + assert_no_event_reported do + # No events are reported here + end + end + + test "#assert_no_event_reported with provided name" do + assert_no_event_reported("user.created") do + @reporter.notify("another.event") + end + end + + test "#assert_no_event_reported with payload" do + assert_no_event_reported("user.created", payload: { id: 123, name: "Sazz Pataki" }) do + @reporter.notify("user.created", { id: 123, name: "Mabel Mora" }) + end + + assert_no_event_reported("user.created", payload: { name: "Sazz Pataki" }) do + @reporter.notify("user.created") + end + end + + test "#assert_no_event_reported with tags" do + assert_no_event_reported("user.created", tags: { api: true, zip_code: 10003 }) do + @reporter.tagged(api: false, zip_code: 10003) do + @reporter.notify("user.created") + end + end + + assert_no_event_reported("user.created", tags: { api: true }) do + @reporter.notify("user.created") + end + end + + test "#assert_event_reported fails when event is not reported" do + e = assert_raises(Minitest::Assertion) do + assert_event_reported("user.created") do + # No events are reported here + end + end + + assert_equal "Expected an event to be reported, but there were no events reported.", e.message + end + + test "#assert_event_reported fails when different event is reported" do + e = assert_raises(Minitest::Assertion) do + assert_event_reported("user.created", payload: { id: 123 }) do + @reporter.notify("another.event", { id: 123, name: "John Doe" }) + end + end + + assert_match(/Expected an event to be reported matching:/, e.message) + assert_match(/name: user\.created/, e.message) + assert_match(/but none of the 1 reported events matched:/, e.message) + assert_match(/another\.event/, e.message) + end + + test "#assert_no_event_reported fails when event is reported" do + payload = { id: 123, name: "John Doe" } + e = assert_raises(Minitest::Assertion) do + assert_no_event_reported("user.created") do + @reporter.notify("user.created", payload) + end + end + + assert_match(/Expected no 'user\.created' event to be reported, but found:/, e.message) + assert_match(/user\.created/, e.message) + end + end + end +end diff --git a/guides/source/configuring.md b/guides/source/configuring.md index 2f85c3a01da14..b990dd6a01fd4 100644 --- a/guides/source/configuring.md +++ b/guides/source/configuring.md @@ -2952,6 +2952,37 @@ The default value depends on the `config.load_defaults` target version: [ActiveSupport::Cache::Store#fetch]: https://api.rubyonrails.org/classes/ActiveSupport/Cache/Store.html#method-i-fetch [ActiveSupport::Cache::Store#write]: https://api.rubyonrails.org/classes/ActiveSupport/Cache/Store.html#method-i-write +#### `config.active_support.event_reporter_context_store` + +Configures a custom context store for the Event Reporter. The context store is used to manage metadata that should be attached to every event emitted by the reporter. + +By default, the Event Reporter uses `ActiveSupport::EventContext` which stores context in fiber-local storage. + +To use a custom context store, set this config to a class that implements the context store interface: + +```ruby +# config/application.rb +config.active_support.event_reporter_context_store = CustomContextStore + +class CustomContextStore + class << self + def context + # Return the context hash + end + + def set_context(context_hash) + # Append context_hash to the existing context store + end + + def clear + # Clear the stored context + end + end +end +``` + +Defaults to `nil`, which means the default `ActiveSupport::EventContext` store is used. + ### Configuring Active Job `config.active_job` provides the following configuration options: diff --git a/railties/lib/rails.rb b/railties/lib/rails.rb index 0944bf6c92fa2..fd65dab137f1b 100644 --- a/railties/lib/rails.rb +++ b/railties/lib/rails.rb @@ -93,6 +93,14 @@ def error ActiveSupport.error_reporter end + # Returns the ActiveSupport::EventReporter of the current \Rails project, + # otherwise it returns +nil+ if there is no project. + # + # Rails.event.notify("my_event", { message: "Hello, world!" }) + def event + ActiveSupport.event_reporter + end + # Returns all \Rails groups for loading based on: # # * The \Rails environment; diff --git a/railties/lib/rails/application/bootstrap.rb b/railties/lib/rails/application/bootstrap.rb index 562204c0fe2c3..deafe5a38287a 100644 --- a/railties/lib/rails/application/bootstrap.rb +++ b/railties/lib/rails/application/bootstrap.rb @@ -73,6 +73,10 @@ module Bootstrap end end + initializer :initialize_event_reporter, group: :all do + Rails.event.raise_on_error = config.consider_all_requests_local + end + # Initialize cache early in the stack so railties can make use of it. initializer :initialize_cache, group: :all do cache_format_version = config.active_support.delete(:cache_format_version) From c091113adbf721e3c137eb635e2a513be356c2f7 Mon Sep 17 00:00:00 2001 From: Jared Armstrong Date: Mon, 30 Sep 2024 13:48:59 +1300 Subject: [PATCH 0453/1075] Refactor BroadcastLogger; only execute blocks once [Fixes #49745 #52876] [Related #51883 #49771] This commit refactors the implementation of dispatching method calls to broadcasted loggers. The updated implementation ensures that any block is only executed once. The first logger would recieve the block as per normal, but subsequent loggers would only yield the result of the initial execution. The updated implementation of `dispatch` opened up an opportunity to refactor the way each Logger method is delegatated to broadcasted loggers - simplifying the delegator definitions. Prior to these changes, BroadcastLoggers would iterate each broadcast and re-execute the user provided block for each. The consumer of any Logger would reasonably expect than when calling a method with a block, that the block would only execute a single time. That is, the fact that a Logger is a BroadcastLogger should be irrelevant to consumer. The most common example of this is when using ActiveSupport::TaggedLogging and wrapping behaviour in a `tagged(*tags) { }` block. But this also applies when using the block form `info`, `warn` etc. If a BroadcastLogger is used, and there are multiple loggers being broadcast to, then calling one of these methods with a block would result in the block being executed multiple times. For example: ```ruby broadcasts = ActiveSupport::BroadcastLogger.new( *Array.new(2) { ActiveSupport::Logger.new(STDOUT) } ) number = 0 broadcasts.info { number += 1 "Updated number to #{number}" } # Expected: # Updated number to 1 # Updated number to 1 # # Actual: # Updated number to 1 # Updated number to 2 ``` After these changes, the behaviour of BroadcastLogger reflects the expected behaviour above. --- .../lib/active_support/broadcast_logger.rb | 105 ++++++++---------- activesupport/test/broadcast_logger_test.rb | 41 ++++--- railties/lib/rails/application/bootstrap.rb | 4 +- 3 files changed, 75 insertions(+), 75 deletions(-) diff --git a/activesupport/lib/active_support/broadcast_logger.rb b/activesupport/lib/active_support/broadcast_logger.rb index ae3db20509dd7..057b42a7c0a84 100644 --- a/activesupport/lib/active_support/broadcast_logger.rb +++ b/activesupport/lib/active_support/broadcast_logger.rb @@ -76,7 +76,6 @@ class BroadcastLogger # Returns all the logger that are part of this broadcast. attr_reader :broadcasts - attr_reader :formatter attr_accessor :progname def initialize(*loggers) @@ -105,62 +104,36 @@ 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(...) - dispatch { |logger| logger.add(...) } - end - alias_method :log, :add - - def debug(...) - dispatch { |logger| logger.debug(...) } - end - - def info(...) - dispatch { |logger| logger.info(...) } - end - - def warn(...) - dispatch { |logger| logger.warn(...) } - end - - def error(...) - dispatch { |logger| logger.error(...) } - end - - def fatal(...) - dispatch { |logger| logger.fatal(...) } - end - - def unknown(...) - dispatch { |logger| logger.unknown(...) } + def local_level=(level) + @broadcasts.each do |logger| + logger.local_level = level if logger.respond_to?(:local_level=) + end end - def formatter=(formatter) - dispatch { |logger| logger.formatter = formatter } - - @formatter = formatter - end + def local_level + loggers = @broadcasts.select { |logger| logger.respond_to?(:local_level) } - def level=(level) - dispatch { |logger| logger.level = level } + loggers.map do |logger| + logger.local_level + end.first end - alias_method :sev_threshold=, :level= - def local_level=(level) - dispatch do |logger| - logger.local_level = level if logger.respond_to?(:local_level=) - end + LOGGER_METHODS = %w[ + << log add debug info warn error fatal unknown + level= sev_threshold= close + formatter formatter= + ] # :nodoc: + LOGGER_METHODS.each do |method| + class_eval <<-RUBY, __FILE__, __LINE__ + 1 + def #{method}(...) + dispatch(:#{method}, ...) + end + RUBY end - def close - dispatch { |logger| logger.close } + # Returns the lowest level of all the loggers in the broadcast. + def level + @broadcasts.map(&:level).min end # True if the log level allows entries with severity +Logger::DEBUG+ to be written @@ -171,7 +144,7 @@ def debug? # Sets the log level to +Logger::DEBUG+ for the whole broadcast. def debug! - dispatch { |logger| logger.debug! } + dispatch(:debug!) end # True if the log level allows entries with severity +Logger::INFO+ to be written @@ -182,7 +155,7 @@ def info? # Sets the log level to +Logger::INFO+ for the whole broadcast. def info! - dispatch { |logger| logger.info! } + dispatch(:info!) end # True if the log level allows entries with severity +Logger::WARN+ to be written @@ -193,7 +166,7 @@ def warn? # Sets the log level to +Logger::WARN+ for the whole broadcast. def warn! - dispatch { |logger| logger.warn! } + dispatch(:warn!) end # True if the log level allows entries with severity +Logger::ERROR+ to be written @@ -204,7 +177,7 @@ def error? # Sets the log level to +Logger::ERROR+ for the whole broadcast. def error! - dispatch { |logger| logger.error! } + dispatch(:error!) end # True if the log level allows entries with severity +Logger::FATAL+ to be written @@ -215,21 +188,35 @@ def fatal? # Sets the log level to +Logger::FATAL+ for the whole broadcast. def fatal! - dispatch { |logger| logger.fatal! } + dispatch(:fatal!) end def initialize_copy(other) @broadcasts = [] @progname = other.progname.dup - @formatter = other.formatter.dup broadcast_to(*other.broadcasts.map(&:dup)) end private - def dispatch(&block) - @broadcasts.each { |logger| block.call(logger) } - true + def dispatch(method, *args, **kwargs, &block) + if block_given? + # Maintain semantics that the first logger yields the block + # as normal, but subsequent loggers won't re-execute the block. + # Instead, the initial result is immediately returned. + called, result = false, nil + block = proc { |*args, **kwargs| + if called then result + else + called = true + result = yield(*args, **kwargs) + end + } + end + + @broadcasts.map { |logger| + logger.send(method, *args, **kwargs, &block) + }.first end def method_missing(name, ...) diff --git a/activesupport/test/broadcast_logger_test.rb b/activesupport/test/broadcast_logger_test.rb index 4959359894b01..bf379a61be1bd 100644 --- a/activesupport/test/broadcast_logger_test.rb +++ b/activesupport/test/broadcast_logger_test.rb @@ -268,14 +268,28 @@ def info(msg, &block) assert(logger.foo) end - test "calling a method that accepts a block" do - logger = BroadcastLogger.new(CustomLogger.new) + test "methods are called on each logger" do + calls = 0 + loggers = [CustomLogger.new, FakeLogger.new, CustomLogger.new].each do |logger| + logger.define_singleton_method(:special_method) do + calls += 1 + end + end + logger = BroadcastLogger.new(*loggers) + logger.special_method + assert_equal(3, calls) + end - called = false - logger.bar do - called = true + test "calling a method that accepts a block is yielded only once" do + called = 0 + logger.info do + called += 1 + "Hello" end - assert(called) + + assert_equal 1, called, "block should be called just once" + assert_equal [[::Logger::INFO, "Hello", nil]], log1.adds + assert_equal [[::Logger::INFO, "Hello", nil]], log2.adds end test "calling a method that accepts args" do @@ -356,27 +370,27 @@ def qux(param:) true end - def debug(message, &block) + def debug(message = nil, &block) add(::Logger::DEBUG, message, &block) end - def info(message, &block) + def info(message = nil, &block) add(::Logger::INFO, message, &block) end - def warn(message, &block) + def warn(message = nil, &block) add(::Logger::WARN, message, &block) end - def error(message, &block) + def error(message = nil, &block) add(::Logger::ERROR, message, &block) end - def fatal(message, &block) + def fatal(message = nil, &block) add(::Logger::FATAL, message, &block) end - def unknown(message, &block) + def unknown(message = nil, &block) add(::Logger::UNKNOWN, message, &block) end @@ -385,7 +399,8 @@ def <<(x) end def add(message_level, message = nil, progname = nil, &block) - @adds << [message_level, message, progname] if message_level >= local_level + @adds << [message_level, block_given? ? block.call : message, progname] if message_level >= local_level + true end def debug? diff --git a/railties/lib/rails/application/bootstrap.rb b/railties/lib/rails/application/bootstrap.rb index deafe5a38287a..78ca9950968bb 100644 --- a/railties/lib/rails/application/bootstrap.rb +++ b/railties/lib/rails/application/bootstrap.rb @@ -59,9 +59,7 @@ module Bootstrap end else Rails.logger.level = ActiveSupport::Logger.const_get(config.log_level.to_s.upcase) - broadcast_logger = ActiveSupport::BroadcastLogger.new(Rails.logger) - broadcast_logger.formatter = Rails.logger.formatter - Rails.logger = broadcast_logger + Rails.logger = ActiveSupport::BroadcastLogger.new(Rails.logger) end end From 0a97be361a514f67ef2d026835df774597b90b21 Mon Sep 17 00:00:00 2001 From: Bogdan Gusiev Date: Sat, 5 Oct 2024 15:55:15 +0200 Subject: [PATCH 0454/1075] Add AS::TimeZone#standard_name method AS::TimeZone exposes MAPPING as way to normalize TZ name But using it is pretty inconvinient zone = ActiveSupport::TimeZone['Hawaii'] # Old way ActiveSupport::TimeZone::MAPPING[zone.name] # New way zone.standard_name --- activesupport/CHANGELOG.md | 12 ++++++++++++ activesupport/lib/active_support/values/time_zone.rb | 6 ++++++ activesupport/test/time_zone_test.rb | 6 ++++++ 3 files changed, 24 insertions(+) diff --git a/activesupport/CHANGELOG.md b/activesupport/CHANGELOG.md index 2d88f392eefc0..452fd6c633d6d 100644 --- a/activesupport/CHANGELOG.md +++ b/activesupport/CHANGELOG.md @@ -1,3 +1,15 @@ +* Add `ActiveSupport::TimeZone#standard_name` method. + + ``` ruby + zone = ActiveSupport::TimeZone['Hawaii'] + # Old way + ActiveSupport::TimeZone::MAPPING[zone.name] + # New way + zone.standard_name # => 'Pacific/Honolulu' + ``` + + *Bogdan Gusiev* + * Add Structured Event Reporter, accessible via `Rails.event`. The Event Reporter provides a unified interface for producing structured events in Rails diff --git a/activesupport/lib/active_support/values/time_zone.rb b/activesupport/lib/active_support/values/time_zone.rb index cb7206405f2be..70d25808c32ed 100644 --- a/activesupport/lib/active_support/values/time_zone.rb +++ b/activesupport/lib/active_support/values/time_zone.rb @@ -314,6 +314,12 @@ def initialize(name, utc_offset = nil, tzinfo = nil) end # :startdoc: + # Returns a standard time zone name defined by IANA + # https://www.iana.org/time-zones + def standard_name + MAPPING[name] || name + end + # Returns the offset of this time zone from UTC in seconds. def utc_offset @utc_offset || tzinfo&.current_period&.base_utc_offset diff --git a/activesupport/test/time_zone_test.rb b/activesupport/test/time_zone_test.rb index 5afda30e22a9d..e2fa0e988863f 100644 --- a/activesupport/test/time_zone_test.rb +++ b/activesupport/test/time_zone_test.rb @@ -920,4 +920,10 @@ def test_works_as_ruby_time_zone assert_equal "EDT", time.strftime("%Z") assert_equal true, time.isdst end + + def test_standard_name + assert_equal "America/New_York", ActiveSupport::TimeZone["Eastern Time (US & Canada)"].standard_name + assert_equal "America/Montevideo", ActiveSupport::TimeZone["Montevideo"].standard_name + assert_equal "America/Toronto", ActiveSupport::TimeZone["America/Toronto"].standard_name + end end From 1ca278a6a7a05f9ec8b72bcafc3f935d65059232 Mon Sep 17 00:00:00 2001 From: Zack Deveau Date: Tue, 12 Aug 2025 13:40:00 -0700 Subject: [PATCH 0455/1075] Active Storage: Remove dangerous transformations [CVE-2025-24293] A subset of transformation methods included in the default allowed list still present potential command injection risk to applications accepting arbitrary user input for transformations or their parameters. Doing so is unsupported behavior and should be considered dangerous. --- activestorage/lib/active_storage.rb | 3 --- activestorage/test/models/variant_test.rb | 6 +++--- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/activestorage/lib/active_storage.rb b/activestorage/lib/active_storage.rb index fcda880c218bc..8e9ea3c02558f 100644 --- a/activestorage/lib/active_storage.rb +++ b/activestorage/lib/active_storage.rb @@ -74,7 +74,6 @@ module ActiveStorage "annotate", "antialias", "append", - "apply", "attenuate", "authenticate", "auto_gamma", @@ -215,7 +214,6 @@ module ActiveStorage "linewidth", "liquid_rescale", "list", - "loader", "log", "loop", "lowlight_color", @@ -278,7 +276,6 @@ module ActiveStorage "rotate", "sample", "sampling_factor", - "saver", "scale", "scene", "screen", diff --git a/activestorage/test/models/variant_test.rb b/activestorage/test/models/variant_test.rb index c7a402b1b125d..08945713b0daf 100644 --- a/activestorage/test/models/variant_test.rb +++ b/activestorage/test/models/variant_test.rb @@ -257,7 +257,7 @@ class ActiveStorage::VariantTest < ActiveSupport::TestCase process_variants_with :mini_magick do blob = create_file_blob(filename: "racecar.jpg") assert_raise(ActiveStorage::Transformers::ImageProcessingTransformer::UnsupportedImageProcessingArgument) do - blob.variant(saver: { "-write": "/tmp/file.erb" }).processed + blob.variant(resize: { "-write": "/tmp/file.erb" }).processed end end end @@ -266,11 +266,11 @@ class ActiveStorage::VariantTest < ActiveSupport::TestCase process_variants_with :mini_magick do blob = create_file_blob(filename: "racecar.jpg") assert_raise(ActiveStorage::Transformers::ImageProcessingTransformer::UnsupportedImageProcessingArgument) do - blob.variant(saver: { "something": { "-write": "/tmp/file.erb" } }).processed + blob.variant(resize: { "something": { "-write": "/tmp/file.erb" } }).processed end assert_raise(ActiveStorage::Transformers::ImageProcessingTransformer::UnsupportedImageProcessingArgument) do - blob.variant(saver: { "something": ["-write", "/tmp/file.erb"] }).processed + blob.variant(resize: { "something": ["-write", "/tmp/file.erb"] }).processed end end end From dd6fcc43c4f6cdd27b9373601f8ad2c60ec49031 Mon Sep 17 00:00:00 2001 From: John Hawthorn Date: Mon, 10 Mar 2025 13:44:44 -0700 Subject: [PATCH 0456/1075] Call inspect on ids in RecordNotFound error [CVE-2025-55193] Co-authored-by: Gannon McGibbon --- activerecord/lib/active_record/core.rb | 2 +- activerecord/lib/active_record/relation/finder_methods.rb | 7 ++++--- .../associations/has_many_through_associations_test.rb | 2 +- activerecord/test/cases/finder_test.rb | 4 ++-- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/activerecord/lib/active_record/core.rb b/activerecord/lib/active_record/core.rb index 94c11232c2812..d34c7c3a74a23 100644 --- a/activerecord/lib/active_record/core.rb +++ b/activerecord/lib/active_record/core.rb @@ -276,7 +276,7 @@ def find(*ids) # :nodoc: return super if StatementCache.unsupported_value?(id) cached_find_by([primary_key], [id]) || - raise(RecordNotFound.new("Couldn't find #{name} with '#{primary_key}'=#{id}", name, primary_key, id)) + raise(RecordNotFound.new("Couldn't find #{name} with '#{primary_key}'=#{id.inspect}", name, primary_key, id)) end def find_by(*args) # :nodoc: diff --git a/activerecord/lib/active_record/relation/finder_methods.rb b/activerecord/lib/active_record/relation/finder_methods.rb index 877b573ae194c..12e0a609f93fb 100644 --- a/activerecord/lib/active_record/relation/finder_methods.rb +++ b/activerecord/lib/active_record/relation/finder_methods.rb @@ -424,12 +424,13 @@ def raise_record_not_found_exception!(ids = nil, result_size = nil, expected_siz error << " with#{conditions}" if conditions raise RecordNotFound.new(error, name, key) elsif Array.wrap(ids).size == 1 - error = "Couldn't find #{name} with '#{key}'=#{ids}#{conditions}" + id = Array.wrap(ids)[0] + error = "Couldn't find #{name} with '#{key}'=#{id.inspect}#{conditions}" raise RecordNotFound.new(error, name, key, ids) else error = +"Couldn't find all #{name.pluralize} with '#{key}': " - error << "(#{ids.join(", ")})#{conditions} (found #{result_size} results, but was looking for #{expected_size})." - error << " Couldn't find #{name.pluralize(not_found_ids.size)} with #{key.to_s.pluralize(not_found_ids.size)} #{not_found_ids.join(', ')}." if not_found_ids + error << "(#{ids.map(&:inspect).join(", ")})#{conditions} (found #{result_size} results, but was looking for #{expected_size})." + error << " Couldn't find #{name.pluralize(not_found_ids.size)} with #{key.to_s.pluralize(not_found_ids.size)} #{not_found_ids.map(&:inspect).join(', ')}." if not_found_ids raise RecordNotFound.new(error, name, key, ids) end 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 6c2082a871d22..5a1fd6ca6f998 100644 --- a/activerecord/test/cases/associations/has_many_through_associations_test.rb +++ b/activerecord/test/cases/associations/has_many_through_associations_test.rb @@ -1066,7 +1066,7 @@ def test_collection_singular_ids_through_setter_raises_exception_when_invalid_id author = authors(:david) ids = [categories(:general).name, "Unknown"] e = assert_raises(ActiveRecord::RecordNotFound) { author.essay_category_ids = ids } - msg = "Couldn't find all Categories with 'name': (General, Unknown) (found 1 results, but was looking for 2). Couldn't find Category with name Unknown." + msg = %{Couldn't find all Categories with 'name': ("General", "Unknown") (found 1 results, but was looking for 2). Couldn't find Category with name "Unknown".} assert_equal msg, e.message end diff --git a/activerecord/test/cases/finder_test.rb b/activerecord/test/cases/finder_test.rb index 993e2eb2fa6e8..f51146b1e6ac0 100644 --- a/activerecord/test/cases/finder_test.rb +++ b/activerecord/test/cases/finder_test.rb @@ -1873,7 +1873,7 @@ def test_find_one_message_with_custom_primary_key e = assert_raises(ActiveRecord::RecordNotFound) do model.find "Hello World!" end - assert_equal "Couldn't find MercedesCar with 'name'=Hello World!", e.message + assert_equal %{Couldn't find MercedesCar with 'name'="Hello World!"}, e.message end end @@ -1883,7 +1883,7 @@ def test_find_some_message_with_custom_primary_key e = assert_raises(ActiveRecord::RecordNotFound) do model.find "Hello", "World!" end - assert_equal "Couldn't find all MercedesCars with 'name': (Hello, World!) (found 0 results, but was looking for 2).", e.message + assert_equal %{Couldn't find all MercedesCars with 'name': ("Hello", "World!") (found 0 results, but was looking for 2).}, e.message end end From a99903d464662db6b114b07fa56e90204a3b4778 Mon Sep 17 00:00:00 2001 From: Earlopain <14981592+Earlopain@users.noreply.github.com> Date: Thu, 14 Aug 2025 10:33:40 +0200 Subject: [PATCH 0457/1075] Bump RuboCop to fix some transient failures ``` rubocop --parallel Error: `Rails` cops have been extracted to the `rubocop-rails` gem. (obsolete configuration found in .rubocop.yml, please update it) ```` Like here: * https://buildkite.com/rails/rails/builds/120729/steps/canvas?sid=0198a576-b57c-485b-803a-150b40ee41df * https://buildkite.com/rails/rails/builds/120724/steps/canvas?sid=0198a53d-2d42-49b2-8b35-97c2395f01b2 This is fixed since RuboCop 1.74 with https://github.com/rubocop/rubocop/pull/13954 but I can't tell why it would start only now. `rubocop-md` needs a bump as well to silence some deprecation about a renamed cop. Other changes are what rubocop now wants --- Gemfile | 2 +- Gemfile.lock | 17 +++++++++-------- Rakefile | 2 +- actionmailbox/lib/action_mailbox/engine.rb | 2 +- actionpack/test/dispatch/test_response_test.rb | 2 +- .../lib/action_view/helpers/asset_tag_helper.rb | 4 ++-- activerecord/test/cases/locking_test.rb | 2 +- 7 files changed, 16 insertions(+), 15 deletions(-) diff --git a/Gemfile b/Gemfile index 6512ce889c746..2ac43961e934d 100644 --- a/Gemfile +++ b/Gemfile @@ -48,7 +48,7 @@ gem "prism" group :rubocop do # Rubocop has to be locked in the Gemfile because CI ignores Gemfile.lock # We don't want rubocop to start failing whenever rubocop makes a new release. - gem "rubocop", "< 1.73", require: false + gem "rubocop", "1.79.2", require: false gem "rubocop-minitest", require: false gem "rubocop-packaging", require: false gem "rubocop-performance", require: false diff --git a/Gemfile.lock b/Gemfile.lock index 6a1247f28071d..db91c7e62b58d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -423,7 +423,7 @@ GEM os (1.1.4) ostruct (0.6.1) parallel (1.26.3) - parser (3.3.6.0) + parser (3.3.9.0) ast (~> 2.4.1) racc path_expander (1.1.3) @@ -431,7 +431,7 @@ GEM pp (0.6.2) prettyprint prettyprint (0.2.0) - prism (1.3.0) + prism (1.4.0) propshaft (1.2.0) actionpack (>= 7.0.0) activesupport (>= 7.0.0) @@ -512,7 +512,7 @@ GEM retriable (3.1.2) rexml (3.4.0) rouge (4.5.1) - rubocop (1.72.2) + rubocop (1.79.2) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) @@ -520,12 +520,13 @@ GEM parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 2.9.3, < 3.0) - rubocop-ast (>= 1.38.0, < 2.0) + rubocop-ast (>= 1.46.0, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 4.0) - rubocop-ast (1.38.1) - parser (>= 3.3.1.0) - rubocop-md (2.0.0) + rubocop-ast (1.46.0) + parser (>= 3.3.7.2) + prism (~> 1.4) + rubocop-md (2.0.1) lint_roller (~> 1.1) rubocop (>= 1.72.1) rubocop-minitest (0.37.1) @@ -793,7 +794,7 @@ DEPENDENCIES resque-scheduler rexml rouge - rubocop (< 1.73) + rubocop (= 1.79.2) rubocop-md rubocop-minitest rubocop-packaging diff --git a/Rakefile b/Rakefile index 2d9c2657d9825..930b680504ebc 100644 --- a/Rakefile +++ b/Rakefile @@ -174,7 +174,7 @@ task :smoke, [:frameworks, :isolated] do |task, args| frameworks = Releaser::FRAMEWORKS end - isolated = args[:isolated].nil? ? true : args[:isolated] == "true" + isolated = args[:isolated].nil? || args[:isolated] == "true" test_task = isolated ? "test:isolated" : "test" (frameworks - ["activerecord"]).each do |project| diff --git a/actionmailbox/lib/action_mailbox/engine.rb b/actionmailbox/lib/action_mailbox/engine.rb index d0dc5c8ce29a7..4f745acd815f3 100644 --- a/actionmailbox/lib/action_mailbox/engine.rb +++ b/actionmailbox/lib/action_mailbox/engine.rb @@ -29,7 +29,7 @@ class Engine < Rails::Engine initializer "action_mailbox.config" do config.after_initialize do |app| ActionMailbox.logger = app.config.action_mailbox.logger || Rails.logger - ActionMailbox.incinerate = app.config.action_mailbox.incinerate.nil? ? true : app.config.action_mailbox.incinerate + ActionMailbox.incinerate = app.config.action_mailbox.incinerate.nil? || app.config.action_mailbox.incinerate ActionMailbox.incinerate_after = app.config.action_mailbox.incinerate_after || 30.days ActionMailbox.queues = app.config.action_mailbox.queues || {} ActionMailbox.ingress = app.config.action_mailbox.ingress diff --git a/actionpack/test/dispatch/test_response_test.rb b/actionpack/test/dispatch/test_response_test.rb index c96194782cb90..81acc429fbf39 100644 --- a/actionpack/test/dispatch/test_response_test.rb +++ b/actionpack/test/dispatch/test_response_test.rb @@ -62,7 +62,7 @@ def assert_response_code_range(range, predicate) HTML html = response.parsed_body - html.at("main") => {name:, content:} + html.at("main") => { name:, content: } assert_equal "main", name assert_equal "Some main content", content diff --git a/actionview/lib/action_view/helpers/asset_tag_helper.rb b/actionview/lib/action_view/helpers/asset_tag_helper.rb index e4b7ccf80c283..bca6a602e557b 100644 --- a/actionview/lib/action_view/helpers/asset_tag_helper.rb +++ b/actionview/lib/action_view/helpers/asset_tag_helper.rb @@ -117,7 +117,7 @@ def javascript_include_tag(*sources) path_options = options.extract!("protocol", "extname", "host", "skip_pipeline").symbolize_keys preload_links = [] use_preload_links_header = options["preload_links_header"].nil? ? preload_links_header : options.delete("preload_links_header") - nopush = options["nopush"].nil? ? true : options.delete("nopush") + nopush = options["nopush"].nil? || options.delete("nopush") crossorigin = options.delete("crossorigin") crossorigin = "anonymous" if crossorigin == true integrity = options["integrity"] @@ -211,7 +211,7 @@ def stylesheet_link_tag(*sources) preload_links = [] crossorigin = options.delete("crossorigin") crossorigin = "anonymous" if crossorigin == true - nopush = options["nopush"].nil? ? true : options.delete("nopush") + nopush = options["nopush"].nil? || options.delete("nopush") integrity = options["integrity"] sources_tags = sources.uniq.map { |source| diff --git a/activerecord/test/cases/locking_test.rb b/activerecord/test/cases/locking_test.rb index ba231a430b3e4..1e2b834ea1277 100644 --- a/activerecord/test/cases/locking_test.rb +++ b/activerecord/test/cases/locking_test.rb @@ -859,7 +859,7 @@ def duel(&block) class PessimisticLockingWhilePreventingWritesTest < ActiveRecord::TestCase CUSTOM_LOCK = if current_adapter?(:SQLite3Adapter) - true # no-op + "FOR UPDATE" # no-op else "FOR SHARE" end From 0e84ad3751d252ea1f3e73c45a1d65d8f326393b Mon Sep 17 00:00:00 2001 From: Yasuo Honda Date: Thu, 14 Aug 2025 18:11:46 +0900 Subject: [PATCH 0458/1075] Add `cgi` gem to Gemfile until newer rouge gem is released This commit addresses this Rails CI Nightly failure below. https://buildkite.com/rails/rails-nightly/builds/2679#0198a53d-2b95-4767-95aa-e54f1297493d/1193-1197 - Errors addressed by this commit ``` $ ruby -v ruby 3.5.0dev (2025-08-12T14:43:46Z master c5c894c6e4) +PRISM [x86_64-linux] $ cd guides $ bundle install $ bundle exec rake guides:lint ... snip ... Generating active_record_validations.md as active_record_validations.html Generating 5_1_release_notes.md as 5_1_release_notes.html Generating asset_pipeline.md as asset_pipeline.html /home/yahonda/.local/share/mise/installs/ruby/trunk/lib/ruby/gems/3.5.0+2/gems/rouge-4.5.1/lib/rouge/lexer.rb:55:in 'Rouge::Lexer.lookup_fancy': undefined method 'parse' for class CGI (NoMethodError) opts = CGI.parse(opts || '').map do |k, vals| ^^^^^^ from /home/yahonda/.local/share/mise/installs/ruby/trunk/lib/ruby/gems/3.5.0+2/gems/rouge-4.5.1/lib/rouge/lexer.rb:95:in 'Rouge::Lexer.find_fancy' from /home/yahonda/src/github.com/rails/rails/guides/rails_guides/markdown/renderer.rb:35:in 'RailsGuides::Markdown::Renderer#block_code' from /home/yahonda/src/github.com/rails/rails/guides/rails_guides/markdown.rb:90:in 'Redcarpet::Markdown#render' from /home/yahonda/src/github.com/rails/rails/guides/rails_guides/markdown.rb:90:in 'RailsGuides::Markdown#generate_body' from /home/yahonda/src/github.com/rails/rails/guides/rails_guides/markdown.rb:28:in 'RailsGuides::Markdown#render' from /home/yahonda/src/github.com/rails/rails/guides/rails_guides/generator.rb:214:in 'RailsGuides::Generator#generate_guide' from /home/yahonda/src/github.com/rails/rails/guides/rails_guides/generator.rb:108:in 'block in RailsGuides::Generator#generate_guides' from /home/yahonda/src/github.com/rails/rails/guides/rails_guides/generator.rb:106:in 'Array#each' from /home/yahonda/src/github.com/rails/rails/guides/rails_guides/generator.rb:106:in 'RailsGuides::Generator#generate_guides' from /home/yahonda/src/github.com/rails/rails/guides/rails_guides/generator.rb:52:in 'RailsGuides::Generator#generate' from rails_guides.rb:31:in '
' rake aborted! Command failed with status (1): [/home/yahonda/.local/share/mise/installs/ruby/trunk/bin/ruby -Eutf-8:utf-8 rails_guides.rb] /home/yahonda/src/github.com/rails/rails/guides/Rakefile:33:in 'block (3 levels) in ' /home/yahonda/.local/share/mise/installs/ruby/trunk/bin/bundle:25:in '
' Tasks: TOP => guides:lint => guides:lint:check_links (See full trace by running task with --trace) $ ``` This workaround commit can be reverted when newer version of rouge gem is relased including https://github.com/rouge-ruby/rouge/pull/2131 - Ruby 3.5 removes cgi by default https://bugs.ruby-lang.org/issues/21258 https://github.com/ruby/ruby/pull/13275 --- Gemfile | 2 ++ Gemfile.lock | 2 ++ 2 files changed, 4 insertions(+) diff --git a/Gemfile b/Gemfile index 6512ce889c746..fcba6bb0e0c84 100644 --- a/Gemfile +++ b/Gemfile @@ -69,6 +69,8 @@ group :doc do gem "redcarpet", "~> 3.6.1", platforms: :ruby gem "w3c_validators", "~> 1.3.6" gem "rouge" + # Workaround until https://github.com/rouge-ruby/rouge/pull/2131 is merged and released + gem "cgi", require: false gem "rubyzip", "~> 2.0" end diff --git a/Gemfile.lock b/Gemfile.lock index 6a1247f28071d..0936d916e5d0f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -182,6 +182,7 @@ GEM rack-test (>= 0.6.3) regexp_parser (>= 1.5, < 3.0) xpath (~> 3.2) + cgi (0.5.0) chef-utils (18.6.2) concurrent-ruby childprocess (5.1.0) @@ -752,6 +753,7 @@ DEPENDENCIES brakeman bundler-audit capybara (>= 3.39) + cgi connection_pool cssbundling-rails dalli (>= 3.0.1) From 2e7e5b9dbf87dab013153444db1fd386a752817b Mon Sep 17 00:00:00 2001 From: Tobias Amft Date: Thu, 14 Aug 2025 16:41:21 +0200 Subject: [PATCH 0459/1075] Replace America/Godthab with America/Nuuk According to IANA, America/Godthab is just a link to America/Godthab. Debian Trixie for example has already removed America/Godthab from tzdata. Sources - https://data.iana.org/time-zones/data/backward - https://data.iana.org/time-zones/tzdb/zone.tab - https://packages.debian.org/bookworm/all/tzdata/filelist - https://packages.debian.org/trixie/all/tzdata/filelist --- activesupport/lib/active_support/values/time_zone.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/activesupport/lib/active_support/values/time_zone.rb b/activesupport/lib/active_support/values/time_zone.rb index 70d25808c32ed..7934a8505b4ba 100644 --- a/activesupport/lib/active_support/values/time_zone.rb +++ b/activesupport/lib/active_support/values/time_zone.rb @@ -64,7 +64,7 @@ class TimeZone "Montevideo" => "America/Montevideo", "Georgetown" => "America/Guyana", "Puerto Rico" => "America/Puerto_Rico", - "Greenland" => "America/Godthab", + "Greenland" => "America/Nuuk", "Mid-Atlantic" => "Atlantic/South_Georgia", "Azores" => "Atlantic/Azores", "Cape Verde Is." => "Atlantic/Cape_Verde", From 2b476d2a20b4c4f46ea94288c854162bcd62ee20 Mon Sep 17 00:00:00 2001 From: Tobias Bales Date: Fri, 15 Aug 2025 10:23:04 +0200 Subject: [PATCH 0460/1075] Clean up action pack redirect tests --- actionpack/test/controller/redirect_test.rb | 325 ++++++++++---------- 1 file changed, 155 insertions(+), 170 deletions(-) diff --git a/actionpack/test/controller/redirect_test.rb b/actionpack/test/controller/redirect_test.rb index 6e611ff74d56c..f4e51b9efb230 100644 --- a/actionpack/test/controller/redirect_test.rb +++ b/actionpack/test/controller/redirect_test.rb @@ -652,224 +652,182 @@ def test_redirect_to_external_with_rescue end def test_redirect_to_path_relative_url_with_log - old_config = ActionController::Base.action_on_path_relative_redirect - ActionController::Base.action_on_path_relative_redirect = :log - - logger = ActiveSupport::LogSubscriber::TestHelper::MockLogger.new - old_logger = ActionController::Base.logger - ActionController::Base.logger = logger - - get :redirect_to_path_relative_url - assert_response :redirect - assert_equal "http://test.hostexample.com", redirect_to_url - assert_match(/Path relative URL redirect detected: "example.com"/, logger.logged(:warn).last) - ensure - ActionController::Base.logger = old_logger - ActionController::Base.action_on_path_relative_redirect = old_config + with_path_relative_redirect(:log) do + with_logger do |logger| + get :redirect_to_path_relative_url + assert_response :redirect + assert_equal "http://test.hostexample.com", redirect_to_url + assert_logged(/Path relative URL redirect detected: "example.com"/, logger) + end + end end def test_redirect_to_path_relative_url_starting_with_an_at_with_log - old_config = ActionController::Base.action_on_path_relative_redirect - ActionController::Base.action_on_path_relative_redirect = :log - - logger = ActiveSupport::LogSubscriber::TestHelper::MockLogger.new - old_logger = ActionController::Base.logger - ActionController::Base.logger = logger - - get :redirect_to_path_relative_url_starting_with_an_at - assert_response :redirect - assert_equal "http://test.host@example.com", redirect_to_url - assert_match(/Path relative URL redirect detected: "@example.com"/, logger.logged(:warn).last) - ensure - ActionController::Base.logger = old_logger - ActionController::Base.action_on_path_relative_redirect = old_config + with_path_relative_redirect(:log) do + with_logger do |logger| + get :redirect_to_path_relative_url_starting_with_an_at + assert_response :redirect + assert_equal "http://test.host@example.com", redirect_to_url + assert_logged(/Path relative URL redirect detected: "@example.com"/, logger) + end + end end def test_redirect_to_path_relative_url_starting_with_an_at_with_notify - old_config = ActionController::Base.action_on_path_relative_redirect - ActionController::Base.action_on_path_relative_redirect = :notify - - events = [] - ActiveSupport::Notifications.subscribe("unsafe_redirect.action_controller") do |*args| - events << ActiveSupport::Notifications::Event.new(*args) - end - - get :redirect_to_path_relative_url_starting_with_an_at + with_path_relative_redirect(:notify) do + events = [] + ActiveSupport::Notifications.subscribe("unsafe_redirect.action_controller") do |*args| + events << ActiveSupport::Notifications::Event.new(*args) + end - assert_response :redirect - assert_equal "http://test.host@example.com", redirect_to_url + get :redirect_to_path_relative_url_starting_with_an_at - assert_equal 1, events.size - event = events.first - assert_equal "@example.com", event.payload[:url] - assert_equal 'Path relative URL redirect detected: "@example.com"', event.payload[:message] - assert_kind_of Array, event.payload[:stack_trace] - assert event.payload[:stack_trace].any? { |line| line.include?("redirect_to_path_relative_url_starting_with_an_at") } - ensure - ActiveSupport::Notifications.unsubscribe("unsafe_redirect.action_controller") - ActionController::Base.action_on_path_relative_redirect = old_config + assert_response :redirect + assert_equal "http://test.host@example.com", redirect_to_url + + assert_equal 1, events.size + event = events.first + assert_equal "@example.com", event.payload[:url] + assert_equal 'Path relative URL redirect detected: "@example.com"', event.payload[:message] + assert_kind_of Array, event.payload[:stack_trace] + assert event.payload[:stack_trace].any? { |line| line.include?("redirect_to_path_relative_url_starting_with_an_at") } + ensure + ActiveSupport::Notifications.unsubscribe("unsafe_redirect.action_controller") + end end def test_redirect_to_path_relative_url_with_notify - old_config = ActionController::Base.action_on_path_relative_redirect - ActionController::Base.action_on_path_relative_redirect = :notify - - events = [] - ActiveSupport::Notifications.subscribe("unsafe_redirect.action_controller") do |*args| - events << ActiveSupport::Notifications::Event.new(*args) - end - - get :redirect_to_path_relative_url + with_path_relative_redirect(:notify) do + events = [] + ActiveSupport::Notifications.subscribe("unsafe_redirect.action_controller") do |*args| + events << ActiveSupport::Notifications::Event.new(*args) + end - assert_response :redirect - assert_equal "http://test.hostexample.com", redirect_to_url + get :redirect_to_path_relative_url - assert_equal 1, events.size - event = events.first - assert_equal "example.com", event.payload[:url] - assert_equal 'Path relative URL redirect detected: "example.com"', event.payload[:message] - assert_kind_of Array, event.payload[:stack_trace] - assert event.payload[:stack_trace].any? { |line| line.include?("redirect_to_path_relative_url") } - ensure - ActiveSupport::Notifications.unsubscribe("unsafe_redirect.action_controller") - ActionController::Base.action_on_path_relative_redirect = old_config + assert_response :redirect + assert_equal "http://test.hostexample.com", redirect_to_url + + assert_equal 1, events.size + event = events.first + assert_equal "example.com", event.payload[:url] + assert_equal 'Path relative URL redirect detected: "example.com"', event.payload[:message] + assert_kind_of Array, event.payload[:stack_trace] + assert event.payload[:stack_trace].any? { |line| line.include?("redirect_to_path_relative_url") } + ensure + ActiveSupport::Notifications.unsubscribe("unsafe_redirect.action_controller") + end end def test_redirect_to_path_relative_url_with_raise - old_config = ActionController::Base.action_on_path_relative_redirect - ActionController::Base.action_on_path_relative_redirect = :raise + with_path_relative_redirect(:raise) do + error = assert_raise(ActionController::Redirecting::UnsafeRedirectError) do + get :redirect_to_path_relative_url + end - error = assert_raise(ActionController::Redirecting::UnsafeRedirectError) do - get :redirect_to_path_relative_url + assert_equal 'Path relative URL redirect detected: "example.com"', error.message end - - assert_equal 'Path relative URL redirect detected: "example.com"', error.message - ensure - ActionController::Base.action_on_path_relative_redirect = old_config end def test_redirect_to_path_relative_url_starting_with_an_at_with_raise - old_config = ActionController::Base.action_on_path_relative_redirect - ActionController::Base.action_on_path_relative_redirect = :raise + with_path_relative_redirect(:raise) do + error = assert_raise(ActionController::Redirecting::UnsafeRedirectError) do + get :redirect_to_path_relative_url_starting_with_an_at + end - error = assert_raise(ActionController::Redirecting::UnsafeRedirectError) do - get :redirect_to_path_relative_url_starting_with_an_at + assert_equal 'Path relative URL redirect detected: "@example.com"', error.message end - - assert_equal 'Path relative URL redirect detected: "@example.com"', error.message - ensure - ActionController::Base.action_on_path_relative_redirect = old_config end def test_redirect_to_absolute_url_does_not_log - old_config = ActionController::Base.action_on_path_relative_redirect - ActionController::Base.action_on_path_relative_redirect = :log - - logger = ActiveSupport::LogSubscriber::TestHelper::MockLogger.new - old_logger = ActionController::Base.logger - ActionController::Base.logger = logger - - get :redirect_to_url - assert_response :redirect - assert_equal "http://www.rubyonrails.org/", redirect_to_url - assert_empty logger.logged(:warn) + with_path_relative_redirect(:log) do + with_logger do |logger| + get :redirect_to_url + assert_response :redirect + assert_equal "http://www.rubyonrails.org/", redirect_to_url + assert_not_logged(/Path relative URL redirect detected/, logger) + end - get :relative_url_redirect_with_status - assert_response :redirect - assert_equal "http://test.host/things/stuff", redirect_to_url - assert_empty logger.logged(:warn) - ensure - ActionController::Base.logger = old_logger - ActionController::Base.action_on_path_relative_redirect = old_config + with_logger do |logger| + get :relative_url_redirect_with_status + assert_response :redirect + assert_equal "http://test.host/things/stuff", redirect_to_url + assert_empty logger.logged(:warn) + end + end end def test_redirect_to_absolute_url_does_not_notify - old_config = ActionController::Base.action_on_path_relative_redirect - ActionController::Base.action_on_path_relative_redirect = :notify - - events = [] - ActiveSupport::Notifications.subscribe("unsafe_redirect.action_controller") do |*args| - events << ActiveSupport::Notifications::Event.new(*args) - end + with_path_relative_redirect(:notify) do + events = [] + ActiveSupport::Notifications.subscribe("unsafe_redirect.action_controller") do |*args| + events << ActiveSupport::Notifications::Event.new(*args) + end - get :redirect_to_url - assert_response :redirect - assert_equal "http://www.rubyonrails.org/", redirect_to_url - assert_empty events + get :redirect_to_url + assert_response :redirect + assert_equal "http://www.rubyonrails.org/", redirect_to_url + assert_empty events - get :relative_url_redirect_with_status - assert_response :redirect - assert_equal "http://test.host/things/stuff", redirect_to_url - assert_empty events - ensure - ActiveSupport::Notifications.unsubscribe("unsafe_redirect.action_controller") - ActionController::Base.action_on_path_relative_redirect = old_config + get :relative_url_redirect_with_status + assert_response :redirect + assert_equal "http://test.host/things/stuff", redirect_to_url + assert_empty events + ensure + ActiveSupport::Notifications.unsubscribe("unsafe_redirect.action_controller") + end end def test_redirect_to_absolute_url_does_not_raise - old_config = ActionController::Base.action_on_path_relative_redirect - ActionController::Base.action_on_path_relative_redirect = :raise - - get :redirect_to_url - assert_response :redirect - assert_equal "http://www.rubyonrails.org/", redirect_to_url + with_path_relative_redirect(:raise) do + get :redirect_to_url + assert_response :redirect + assert_equal "http://www.rubyonrails.org/", redirect_to_url - get :relative_url_redirect_with_status - assert_response :redirect - assert_equal "http://test.host/things/stuff", redirect_to_url + get :relative_url_redirect_with_status + assert_response :redirect + assert_equal "http://test.host/things/stuff", redirect_to_url - get :redirect_to_url_with_network_path_reference - assert_response :redirect - assert_equal "//www.rubyonrails.org/", redirect_to_url - ensure - ActionController::Base.action_on_path_relative_redirect = old_config + get :redirect_to_url_with_network_path_reference + assert_response :redirect + assert_equal "//www.rubyonrails.org/", redirect_to_url + end end def test_redirect_to_query_string_url_does_not_trigger_path_relative_warning_with_log - old_config = ActionController::Base.action_on_path_relative_redirect - ActionController::Base.action_on_path_relative_redirect = :log - - logger = ActiveSupport::LogSubscriber::TestHelper::MockLogger.new - old_logger = ActionController::Base.logger - ActionController::Base.logger = logger - - get :redirect_to_query_string_url - assert_response :redirect - assert_equal "http://test.host?foo=bar", redirect_to_url - assert_empty logger.logged(:warn) - ensure - ActionController::Base.logger = old_logger - ActionController::Base.action_on_path_relative_redirect = old_config + with_path_relative_redirect(:log) do + with_logger do |logger| + get :redirect_to_query_string_url + assert_response :redirect + assert_equal "http://test.host?foo=bar", redirect_to_url + assert_not_logged(/Path relative URL redirect detected/, logger) + end + end end def test_redirect_to_query_string_url_does_not_trigger_path_relative_warning_with_notify - old_config = ActionController::Base.action_on_path_relative_redirect - ActionController::Base.action_on_path_relative_redirect = :notify - - events = [] - ActiveSupport::Notifications.subscribe("unsafe_redirect.action_controller") do |*args| - events << ActiveSupport::Notifications::Event.new(*args) - end + with_path_relative_redirect(:notify) do + events = [] + ActiveSupport::Notifications.subscribe("unsafe_redirect.action_controller") do |*args| + events << ActiveSupport::Notifications::Event.new(*args) + end - get :redirect_to_query_string_url - assert_response :redirect - assert_equal "http://test.host?foo=bar", redirect_to_url + get :redirect_to_query_string_url + assert_response :redirect + assert_equal "http://test.host?foo=bar", redirect_to_url - assert_empty events.select { |e| e.payload[:message]&.include?("Path relative URL redirect detected") } - ensure - ActiveSupport::Notifications.unsubscribe("unsafe_redirect.action_controller") - ActionController::Base.action_on_path_relative_redirect = old_config + assert_empty events.select { |e| e.payload[:message]&.include?("Path relative URL redirect detected") } + ensure + ActiveSupport::Notifications.unsubscribe("unsafe_redirect.action_controller") + end end def test_redirect_to_query_string_url_does_not_trigger_path_relative_warning_with_raise - old_config = ActionController::Base.action_on_path_relative_redirect - ActionController::Base.action_on_path_relative_redirect = :raise - - get :redirect_to_query_string_url - assert_response :redirect - assert_equal "http://test.host?foo=bar", redirect_to_url - ensure - ActionController::Base.action_on_path_relative_redirect = old_config + with_path_relative_redirect(:raise) do + get :redirect_to_query_string_url + assert_response :redirect + assert_equal "http://test.host?foo=bar", redirect_to_url + end end def test_redirect_with_allowed_redirect_hosts @@ -893,6 +851,33 @@ def test_not_redirect_with_allowed_redirect_hosts end private + def with_logger + logger = ActiveSupport::LogSubscriber::TestHelper::MockLogger.new + old_logger = ActionController::Base.logger + ActionController::Base.logger = logger + yield logger + ensure + ActionController::Base.logger = old_logger + end + + def assert_logged(pattern, logger) + assert logger.logged(:warn).any? { |msg| msg.match?(pattern) }, + "Expected to find log matching #{pattern.inspect} in: #{logger.logged(:warn).inspect}" + end + + def assert_not_logged(pattern, logger) + assert logger.logged(:warn).none? { |msg| msg.match?(pattern) }, + "Expected not to find log matching #{pattern.inspect} in: #{logger.logged(:warn).inspect}" + end + + def with_path_relative_redirect(action) + old_config = ActionController::Base.action_on_path_relative_redirect + ActionController::Base.action_on_path_relative_redirect = action + yield + ensure + ActionController::Base.action_on_path_relative_redirect = old_config + end + def with_raise_on_open_redirects old_raise_on_open_redirects = ActionController::Base.raise_on_open_redirects ActionController::Base.raise_on_open_redirects = true From 3f2a756b78a546b125a09a4d43a88a9fba51660c Mon Sep 17 00:00:00 2001 From: Tobias Bales Date: Thu, 14 Aug 2025 11:11:17 +0200 Subject: [PATCH 0461/1075] Allow logs and notifications for open redirects Instead of only allowing to raise. This is to aid large codebases to find all places that currently do open redirects but are not called with `allow_other_host: true`. `raise_on_open_redirects` is marked as deprecated and if set to true will set `action_on_open_redirect` to `:raise` --- .../action_controller/metal/redirecting.rb | 49 ++++++- actionpack/lib/action_controller/railtie.rb | 12 ++ actionpack/test/controller/redirect_test.rb | 134 ++++++++++++++++++ guides/source/configuring.md | 25 ++++ .../lib/rails/application/configuration.rb | 4 + .../new_framework_defaults_8_1.rb.tt | 19 +++ 6 files changed, 238 insertions(+), 5 deletions(-) diff --git a/actionpack/lib/action_controller/metal/redirecting.rb b/actionpack/lib/action_controller/metal/redirecting.rb index f9dd112db0cb6..3dc7f92dd7064 100644 --- a/actionpack/lib/action_controller/metal/redirecting.rb +++ b/actionpack/lib/action_controller/metal/redirecting.rb @@ -11,10 +11,23 @@ module Redirecting class UnsafeRedirectError < StandardError; end + class OpenRedirectError < UnsafeRedirectError + def initialize(location) + super("Unsafe redirect to #{location.to_s.truncate(100).inspect}, pass allow_other_host: true to redirect anyway.") + end + end + + class PathRelativeRedirectError < UnsafeRedirectError + def initialize(url) + super("Path relative URL redirect detected: #{url.inspect}") + end + end + ILLEGAL_HEADER_VALUE_REGEX = /[\x00-\x08\x0A-\x1F]/ included do mattr_accessor :raise_on_open_redirects, default: false + mattr_accessor :action_on_open_redirect, default: :log mattr_accessor :action_on_path_relative_redirect, default: :log class_attribute :_allowed_redirect_hosts, :allowed_redirect_hosts_permissions, instance_accessor: false, instance_predicate: false singleton_class.alias_method :allowed_redirect_hosts, :_allowed_redirect_hosts @@ -104,7 +117,11 @@ def allowed_redirect_hosts=(hosts) # # redirect_to params[:redirect_url] # - # Raises UnsafeRedirectError in the case of an unsafe redirect. + # The `action_on_open_redirect` configuration option controls the behavior when an unsafe + # redirect is detected: + # * `:log` - Logs a warning but allows the redirect + # * `:notify` - Sends an ActiveSupport notification for monitoring + # * `:raise` - Raises an UnsafeRedirectError # # To allow any external redirects pass `allow_other_host: true`, though using a # user-provided param in that case is unsafe. @@ -137,7 +154,7 @@ def redirect_to(options = {}, response_options = {}) raise ActionControllerError.new("Cannot redirect to nil!") unless options raise AbstractController::DoubleRenderError if response_body - allow_other_host = response_options.delete(:allow_other_host) { _allow_other_host } + allow_other_host = response_options.delete(:allow_other_host) proposed_status = _extract_redirect_to_status(options, response_options) @@ -244,7 +261,9 @@ def url_from(location) private def _allow_other_host - !raise_on_open_redirects + return false if raise_on_open_redirects + + action_on_open_redirect != :raise end def _extract_redirect_to_status(options, response_options) @@ -258,10 +277,30 @@ def _extract_redirect_to_status(options, response_options) end def _enforce_open_redirect_protection(location, allow_other_host:) + # Explictly allowed other host or host is in allow list allow redirect if allow_other_host || _url_host_allowed?(location) location + # Explicitly disallowed other host + elsif allow_other_host == false + raise OpenRedirectError.new(location) + # Configuration disallows other hosts + elsif !_allow_other_host + raise OpenRedirectError.new(location) + # Log but allow redirect + elsif action_on_open_redirect == :log + logger.warn "Open redirect to #{location.inspect} detected" if logger + location + # Notify but allow redirect + elsif action_on_open_redirect == :notify + ActiveSupport::Notifications.instrument("open_redirect.action_controller", + location: location, + request: request, + stack_trace: caller, + ) + location + # Fall through, should not happen but raise for safety else - raise UnsafeRedirectError, "Unsafe redirect to #{location.truncate(100).inspect}, pass allow_other_host: true to redirect anyway." + raise OpenRedirectError.new(location) end end @@ -301,7 +340,7 @@ def _handle_path_relative_redirect(url) stack_trace: caller ) when :raise - raise UnsafeRedirectError, message + raise PathRelativeRedirectError.new(url) end end end diff --git a/actionpack/lib/action_controller/railtie.rb b/actionpack/lib/action_controller/railtie.rb index 6931cba6c6be2..99aeaa7350cbf 100644 --- a/actionpack/lib/action_controller/railtie.rb +++ b/actionpack/lib/action_controller/railtie.rb @@ -13,6 +13,7 @@ module ActionController class Railtie < Rails::Railtie # :nodoc: config.action_controller = ActiveSupport::OrderedOptions.new config.action_controller.raise_on_open_redirects = false + config.action_controller.action_on_open_redirect = :log config.action_controller.action_on_path_relative_redirect = :log config.action_controller.log_query_tags_around_actions = true config.action_controller.wrap_parameters_by_default = false @@ -103,6 +104,17 @@ class Railtie < Rails::Railtie # :nodoc: end end + initializer "action_controller.open_redirects" do |app| + ActiveSupport.on_load(:action_controller, run_once: true) do + if app.config.action_controller.raise_on_open_redirects != nil + ActiveSupport.deprecator.warn(<<~MSG.squish) + `raise_on_open_redirects` is deprecated and will be removed in a future Rails version. + Use `config.action_controller.action_on_open_redirect = :raise` instead. + MSG + end + end + end + initializer "action_controller.query_log_tags" do |app| query_logs_tags_enabled = app.config.respond_to?(:active_record) && app.config.active_record.query_log_tags_enabled && diff --git a/actionpack/test/controller/redirect_test.rb b/actionpack/test/controller/redirect_test.rb index f4e51b9efb230..43acaa10d5111 100644 --- a/actionpack/test/controller/redirect_test.rb +++ b/actionpack/test/controller/redirect_test.rb @@ -850,6 +850,132 @@ def test_not_redirect_with_allowed_redirect_hosts end end + def test_redirect_to_external_with_action_on_open_redirect_log + with_action_on_open_redirect(:log) do + with_logger do |logger| + get :redirect_to_url + assert_response :redirect + assert_equal "http://www.rubyonrails.org/", redirect_to_url + assert_logged(/Open redirect to "http:\/\/www.rubyonrails.org\/" detected/, logger) + end + end + end + + def test_redirect_to_external_with_action_on_open_redirect_notify + with_action_on_open_redirect(:notify) do + events = [] + ActiveSupport::Notifications.subscribe("open_redirect.action_controller") do |*args| + events << ActiveSupport::Notifications::Event.new(*args) + end + + get :redirect_to_url + assert_response :redirect + assert_equal "http://www.rubyonrails.org/", redirect_to_url + + assert_equal 1, events.size + event = events.first + assert_equal "http://www.rubyonrails.org/", event.payload[:location] + assert_kind_of ActionDispatch::Request, event.payload[:request] + assert_kind_of Array, event.payload[:stack_trace] + ensure + ActiveSupport::Notifications.unsubscribe("open_redirect.action_controller") + end + end + + def test_redirect_to_external_with_action_on_open_redirect_raise + with_action_on_open_redirect(:raise) do + error = assert_raise(ActionController::Redirecting::UnsafeRedirectError) do + get :redirect_to_url + end + assert_equal "Unsafe redirect to \"http://www.rubyonrails.org/\", pass allow_other_host: true to redirect anyway.", error.message + end + end + + def test_redirect_to_external_with_explicit_allow_other_host_false_always_raises + with_action_on_open_redirect(:log) do + get :redirect_to_external_with_rescue + assert_response :ok + assert_equal "caught error", response.body + end + + with_action_on_open_redirect(:notify) do + get :redirect_to_external_with_rescue + assert_response :ok + assert_equal "caught error", response.body + end + + with_action_on_open_redirect(:raise) do + get :redirect_to_external_with_rescue + assert_response :ok + assert_equal "caught error", response.body + end + end + + def test_redirect_back_with_external_referer_and_action_on_open_redirect_log + with_action_on_open_redirect(:log) do + @request.env["HTTP_REFERER"] = "http://www.rubyonrails.org/" + get :redirect_back_with_status + assert_response 307 + assert_equal "http://www.rubyonrails.org/", redirect_to_url + end + end + + def test_redirect_back_with_external_referer_and_action_on_open_redirect_notify + with_action_on_open_redirect(:notify) do + @request.env["HTTP_REFERER"] = "http://www.rubyonrails.org/" + get :redirect_back_with_status + assert_response 307 + assert_equal "http://www.rubyonrails.org/", redirect_to_url + end + end + + def test_redirect_back_with_external_referer_and_action_on_open_redirect_raise + with_action_on_open_redirect(:raise) do + @request.env["HTTP_REFERER"] = "http://www.rubyonrails.org/" + get :redirect_back_with_status + assert_response 307 + assert_equal "http://test.host/things/stuff", redirect_to_url + end + end + + def test_redirect_back_with_external_referer_and_explicit_allow_other_host_false + with_action_on_open_redirect(:log) do + @request.env["HTTP_REFERER"] = "http://another.host/coming/from" + get :safe_redirect_back_with_status + assert_response 307 + assert_equal "http://test.host/things/stuff", redirect_to_url + end + end + + def test_raise_on_open_redirects_overrides_action_on_open_redirect + with_action_on_open_redirect(:log) do + with_raise_on_open_redirects do + error = assert_raise(ActionController::Redirecting::UnsafeRedirectError) do + get :redirect_to_url + end + assert_match(/Unsafe redirect/, error.message) + end + end + end + + def test_action_on_open_redirect_does_not_affect_internal_redirects + with_action_on_open_redirect(:raise) do + get :simple_redirect + assert_response :redirect + assert_equal "http://test.host/redirect/hello_world", redirect_to_url + end + end + + def test_action_on_open_redirect_with_allowed_redirect_hosts + with_action_on_open_redirect(:raise) do + with_allowed_redirect_hosts(hosts: ["www.rubyonrails.org"]) do + get :redirect_to_url + assert_response :redirect + assert_redirected_to "http://www.rubyonrails.org/" + end + end + end + private def with_logger logger = ActiveSupport::LogSubscriber::TestHelper::MockLogger.new @@ -886,6 +1012,14 @@ def with_raise_on_open_redirects ActionController::Base.raise_on_open_redirects = old_raise_on_open_redirects end + def with_action_on_open_redirect(action) + old_action = ActionController::Base.action_on_open_redirect + ActionController::Base.action_on_open_redirect = action + yield + ensure + ActionController::Base.action_on_open_redirect = old_action + end + def with_allowed_redirect_hosts(hosts:) old_allowed_redirect_hosts = ActionController::Base.allowed_redirect_hosts ActionController::Base.allowed_redirect_hosts = hosts diff --git a/guides/source/configuring.md b/guides/source/configuring.md index 6e0de6b0c5e73..d6021fc2f2151 100644 --- a/guides/source/configuring.md +++ b/guides/source/configuring.md @@ -60,6 +60,7 @@ Below are the default values associated with each target version. In cases of co #### Default Values for Target Version 8.1 +- [`config.action_controller.action_on_open_redirect`](#config-action-controller-action-on-open-redirect): `:raise` - [`config.action_controller.action_on_path_relative_redirect`](#config-action-controller-action-on-path-relative-redirect): `:raise` - [`config.action_controller.escape_json_responses`](#config-action-controller-escape-json-responses): `false` - [`config.action_view.remove_hidden_field_autocomplete`](#config-action-view-remove-hidden-field-autocomplete): `true` @@ -1972,6 +1973,30 @@ The default value depends on the `config.load_defaults` target version: [redirect_to]: https://api.rubyonrails.org/classes/ActionController/Redirecting.html#method-i-redirect_to +#### `config.action_controller.action_on_open_redirect` + +Controls how Rails handles open redirect attempts (redirects to external hosts). + +**Note:** This configuration replaces the deprecated [`config.action_controller.raise_on_open_redirects`](#config-action-controller-raise-on-open-redirects) +option, which will be removed in a future Rails version. The new configuration provides more +flexible control over open redirect protection. + +When set to `:log`, Rails will log a warning when an open redirect is detected. +When set to `:notify`, Rails will publish an `open_redirect.action_controller` +notification event. When set to `:raise`, Rails will raise an +`ActionController::Redirecting::UnsafeRedirectError`. + +If `raise_on_open_redirects` is set to `true`, it will take precedence +over this configuration for backward compatibility, effectively forcing `:raise` +behavior. + +The default value depends on the `config.load_defaults` target version: + +| Starting with version | The default value is | +| --------------------- | -------------------- | +| (original) | `:log` | +| 8.1 | `:raise` | + #### `config.action_controller.action_on_path_relative_redirect` Controls how Rails handles paths relative URL redirects. diff --git a/railties/lib/rails/application/configuration.rb b/railties/lib/rails/application/configuration.rb index d35c57a8dfd7a..0b0ee6b107b5f 100644 --- a/railties/lib/rails/application/configuration.rb +++ b/railties/lib/rails/application/configuration.rb @@ -351,6 +351,10 @@ def load_defaults(target_version) when "8.1" load_defaults "8.0" + if respond_to?(:action_controller) + action_controller.action_on_open_redirect = :raise + end + # Development and test environments tend to reload code and # redefine methods (e.g. mocking), hence YJIT isn't generally # faster in these environments. diff --git a/railties/lib/rails/generators/rails/app/templates/config/initializers/new_framework_defaults_8_1.rb.tt b/railties/lib/rails/generators/rails/app/templates/config/initializers/new_framework_defaults_8_1.rb.tt index 6da7e17c36c40..8cf1c50e6f670 100644 --- a/railties/lib/rails/generators/rails/app/templates/config/initializers/new_framework_defaults_8_1.rb.tt +++ b/railties/lib/rails/generators/rails/app/templates/config/initializers/new_framework_defaults_8_1.rb.tt @@ -52,6 +52,25 @@ #++ # Rails.configuration.action_controller.action_on_path_relative_redirect = :raise +### +# Controls how Rails handles open redirect vulnerabilities. +# When set to `:raise`, Rails will raise an `ActionController::Redirecting::UnsafeRedirectError` +# for redirects to external hosts, which helps prevent open redirect attacks. +# +# This configuration replaces the deprecated `raise_on_open_redirects` setting, providing +# the ability for large codebases to safely turn on the protection (after monitoring it with :log/:notifications) +# +# Example: +# redirect_to params[:redirect_url] # May raise UnsafeRedirectError if URL is external +# redirect_to "http://evil.com" # Raises UnsafeRedirectError +# redirect_to "/safe/path" # Works correctly (internal URL) +# redirect_to "http://evil.com", allow_other_host: true # Works (explicitly allowed) +# +# Applications that want to allow these redirects can set the config to `:log` (previous default) +# to only log warnings, or `:notify` to send ActiveSupport notifications for monitoring. +# +#++ +# Rails.configuration.action_controller.action_on_open_redirect = :raise ### # Use a Ruby parser to track dependencies between Action View templates #++ From 3e2f61dc015921d81471c73753fc79d4b00f497d Mon Sep 17 00:00:00 2001 From: George Ma Date: Fri, 15 Aug 2025 09:55:19 -0400 Subject: [PATCH 0462/1075] Add #assert_events_reported test helper The addition of a `#assert_events_reported` helper which was developed with the Rails principles - Optimizes for developer happiness - order doesn't matter, just presence (which also makes more sense given the name `assert_multiple_event_reported`) - [ErrorReporter's `capture_error_reports`](https://edgeapi.rubyonrails.org/classes/ActiveSupport/Testing/ErrorReporterAssertions.html#method-i-capture_error_reports) similarity - focuses on "did these events get reported?" You can imagine a stream of logs ``` "checkout.start" "event-x" "event-y" "event-z" "checkout.end" ``` we can either develop the `assert_events_reported` helper to be 1. Very strict in order (checkout.start must come first, then event-x, event-y, etc) - but this would make it so if a test _only_ cared about `event-x` and `event-z` for example, you would still have to include the other three events in your `assert_events_reported` block 2. Flexible but strict order (events just have to be ordered in the same way they're sent) - This would allow for `event-x` and `event-z` to be `assert_events_reported` asserted as long as they're passed into the assertion in the right order ```ruby assert_events_reported([ { event: "event-x", }, { event: "event-y", }, ]) ``` 3. No ordering (just a count for whether the provided events occur in the block provided) I went with this option as it was the most developer friendly and matches the [ErrorReporter](https://edgeapi.rubyonrails.org/classes/ActiveSupport/Testing/ErrorReporterAssertions.html#method-i-capture_error_reports)'s flexibility --- activesupport/CHANGELOG.md | 15 +++ .../testing/event_reporter_assertions.rb | 55 +++++++++++ .../testing/event_reporter_assertions_test.rb | 91 +++++++++++++++++++ 3 files changed, 161 insertions(+) diff --git a/activesupport/CHANGELOG.md b/activesupport/CHANGELOG.md index 452fd6c633d6d..76c11daa60720 100644 --- a/activesupport/CHANGELOG.md +++ b/activesupport/CHANGELOG.md @@ -1,3 +1,18 @@ +* Add `assert_events_reported` test helper for `ActiveSupport::EventReporter`. + + This new assertion allows testing multiple events in a single block, regardless of order: + + ```ruby + assert_events_reported([ + { name: "user.created", payload: { id: 123 } }, + { name: "email.sent", payload: { to: "user@example.com" } } + ]) do + create_user_and_send_welcome_email + end + ``` + + *George Ma* + * Add `ActiveSupport::TimeZone#standard_name` method. ``` ruby diff --git a/activesupport/lib/active_support/testing/event_reporter_assertions.rb b/activesupport/lib/active_support/testing/event_reporter_assertions.rb index 6ad23d6ff7bd2..bde113410bc47 100644 --- a/activesupport/lib/active_support/testing/event_reporter_assertions.rb +++ b/activesupport/lib/active_support/testing/event_reporter_assertions.rb @@ -158,6 +158,61 @@ def assert_event_reported(name, payload: nil, tags: {}, &block) flunk(message) end end + + # Asserts that the provided events were reported, regardless of order. + # + # assert_events_reported([ + # { name: "user.created", payload: { id: 123 } }, + # { name: "email.sent", payload: { to: "user@example.com" } } + # ]) do + # create_user_and_send_welcome_email + # end + # + # Supports the same payload and tag matching as +assert_event_reported+. + # + # assert_events_reported([ + # { + # name: "process.started", + # payload: { id: 123 }, + # tags: { request_id: /[0-9]+/ } + # }, + # { name: "process.completed" } + # ]) do + # Rails.event.tagged(request_id: "456") do + # start_and_complete_process(123) + # end + # end + def assert_events_reported(expected_events, &block) + events = EventCollector.record(&block) + + if events.empty? && expected_events.size > 0 + flunk("Expected #{expected_events.size} events to be reported, but there were no events reported.") + end + + events_copy = events.dup + + expected_events.each do |expected_event| + name = expected_event[:name] + payload = expected_event[:payload] || {} + tags = expected_event[:tags] || {} + + matching_event_index = events_copy.find_index { |event| event.matches?(name, payload, tags) } + + if matching_event_index + events_copy.delete_at(matching_event_index) + else + message = "Expected an event to be reported matching:\n " \ + "name: #{name.inspect}\n " \ + "payload: #{payload.inspect}\n " \ + "tags: #{tags.inspect}\n" \ + "but none of the #{events.size} reported events matched:\n " \ + "#{events.map(&:inspect).join("\n ")}" + flunk(message) + end + end + + assert(true) + end end end end diff --git a/activesupport/test/testing/event_reporter_assertions_test.rb b/activesupport/test/testing/event_reporter_assertions_test.rb index 7d460a02fb6cd..3ed4f2c74c01d 100644 --- a/activesupport/test/testing/event_reporter_assertions_test.rb +++ b/activesupport/test/testing/event_reporter_assertions_test.rb @@ -120,6 +120,97 @@ class EventReporterAssertionsTest < ActiveSupport::TestCase assert_match(/Expected no 'user\.created' event to be reported, but found:/, e.message) assert_match(/user\.created/, e.message) end + + test "assert_events_reported" do + assert_events_reported([ + { name: "user.created" }, + { name: "email.sent" } + ]) do + @reporter.notify("user.created", { id: 123 }) + @reporter.notify("email.sent", { to: "user@example.com" }) + end + end + + test "assert_events_reported is order agnostic" do + assert_events_reported([ + { name: "user.created", payload: { id: 123 } }, + { name: "email.sent" } + ]) do + @reporter.notify("email.sent", { to: "user@example.com" }) + @reporter.notify("user.created", { id: 123, name: "John" }) + end + end + + test "assert_events_reported ignores extra events" do + assert_events_reported([ + { name: "user.created", payload: { id: 123 } } + ]) do + @reporter.notify("extra_event_1") + @reporter.notify("user.created", { id: 123, name: "John" }) + @reporter.notify("extra_event_2") + @reporter.notify("extra_event_3") + end + end + + test "assert_events_reported works with empty expected array" do + assert_events_reported([]) do + @reporter.notify("some.event") + end + end + + test "assert_events_reported fails when one event missing" do + e = assert_raises(Minitest::Assertion) do + assert_events_reported([ + { name: "user.created" }, + { name: "email.sent" } + ]) do + @reporter.notify("user.created", { id: 123 }) + @reporter.notify("other.event") + end + end + + assert_match(/Expected an event to be reported matching:/, e.message) + assert_match(/name: "email.sent"/, e.message) + assert_match(/but none of the .* reported events matched:/, e.message) + end + + test "assert_events_reported fails when no events reported" do + e = assert_raises(Minitest::Assertion) do + assert_events_reported([ + { name: "user.created" }, + { name: "email.sent" } + ]) do + # No events reported + end + end + + assert_equal "Expected 2 events to be reported, but there were no events reported.", e.message + end + + test "assert_events_reported fails when expecting duplicate events but only one reported" do + e = assert_raises(Minitest::Assertion) do + assert_events_reported([ + { name: "user.created" }, + { name: "user.created" } # Expecting 2 identical events + ]) do + @reporter.notify("user.created") + end + end + + assert_match(/Expected an event to be reported matching:/, e.message) + assert_match(/name: "user.created"/, e.message) + assert_match(/but none of the 1 reported events matched:/, e.message) + end + + test "assert_events_reported passes when expecting duplicate events and both are reported" do + assert_events_reported([ + { name: "user.created", payload: { id: 123 } }, + { name: "user.created", payload: { id: 123 } } + ]) do + @reporter.notify("user.created", { id: 123 }) + @reporter.notify("user.created", { id: 123 }) + end + end end end end From e72735fc10963b68dee229f60c7e13142ca3297e Mon Sep 17 00:00:00 2001 From: Matheus Richard Date: Fri, 15 Aug 2025 12:51:52 -0300 Subject: [PATCH 0463/1075] Fix highlighted code position We have to account for the border width when calculating the position of the highlighted code. This ensures that the highlighted code appears aligned with the rest of the content. --- guides/assets/stylesrc/highlight.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/guides/assets/stylesrc/highlight.scss b/guides/assets/stylesrc/highlight.scss index 4f38ce505483b..a2863c3ea95c3 100644 --- a/guides/assets/stylesrc/highlight.scss +++ b/guides/assets/stylesrc/highlight.scss @@ -63,7 +63,7 @@ &.console, &.erb, &.html { color: #fff; } - .hll { background-color: #3a3939; border-left: 3px solid #00F0FF; margin-left: -5px; padding-left: 5px; padding-right: 5px;} /* $gray-700, $tip */ + .hll { background-color: #3a3939; border-left: 3px solid #00F0FF; margin-left: -8px; padding-left: 5px; padding-right: 5px;} /* $gray-700, $tip */ .c { color: #b4b4b3; } /* Comment */ .err { color: #ff0088; background-color: #1e0010 } /* Error */ .k { color: #9decfc; } /* Keyword */ From 944f2b7748124b5e753317f6646cd159d2f93adf Mon Sep 17 00:00:00 2001 From: Javier Aranda Date: Fri, 15 Aug 2025 20:57:49 +0200 Subject: [PATCH 0464/1075] Update github actions/checkout to v5 --- .github/workflows/devcontainer-shellcheck.yml | 2 +- .github/workflows/devcontainer-smoke-test.yml | 2 +- .github/workflows/rail_inspector.yml | 2 +- .github/workflows/rails-new-docker.yml | 2 +- .github/workflows/rails_releaser_tests.yml | 2 +- .github/workflows/release.yml | 2 +- .../generators/rails/app/templates/github/ci.yml.tt | 10 +++++----- .../generators/rails/plugin/templates/github/ci.yml.tt | 4 ++-- 8 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/workflows/devcontainer-shellcheck.yml b/.github/workflows/devcontainer-shellcheck.yml index 57eb1c3aa8ca4..cb5f314072889 100644 --- a/.github/workflows/devcontainer-shellcheck.yml +++ b/.github/workflows/devcontainer-shellcheck.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout (GitHub) - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Lint Devcontainer Scripts run: | diff --git a/.github/workflows/devcontainer-smoke-test.yml b/.github/workflows/devcontainer-smoke-test.yml index d576e8c486259..872980edcd8f2 100644 --- a/.github/workflows/devcontainer-smoke-test.yml +++ b/.github/workflows/devcontainer-smoke-test.yml @@ -15,7 +15,7 @@ jobs: steps: - name: Checkout (GitHub) - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Login to GitHub Container Registry uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 diff --git a/.github/workflows/rail_inspector.yml b/.github/workflows/rail_inspector.yml index 9bc240995ded5..aa6a83bc6ed23 100644 --- a/.github/workflows/rail_inspector.yml +++ b/.github/workflows/rail_inspector.yml @@ -16,7 +16,7 @@ jobs: name: rail_inspector tests runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Remove Gemfile.lock run: rm -f Gemfile.lock - name: Set up Ruby diff --git a/.github/workflows/rails-new-docker.yml b/.github/workflows/rails-new-docker.yml index eb221395682ae..79bf48d25266a 100644 --- a/.github/workflows/rails-new-docker.yml +++ b/.github/workflows/rails-new-docker.yml @@ -14,7 +14,7 @@ jobs: rails-new-docker: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Remove Gemfile.lock run: rm -f Gemfile.lock - name: Set up Ruby diff --git a/.github/workflows/rails_releaser_tests.yml b/.github/workflows/rails_releaser_tests.yml index f3c2a0f13bb47..92b19b30ddc60 100644 --- a/.github/workflows/rails_releaser_tests.yml +++ b/.github/workflows/rails_releaser_tests.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Set up Ruby uses: ruby/setup-ruby@v1 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e5c84b75eda69..cf956d56ed119 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Set up Ruby uses: ruby/setup-ruby@v1 with: diff --git a/railties/lib/rails/generators/rails/app/templates/github/ci.yml.tt b/railties/lib/rails/generators/rails/app/templates/github/ci.yml.tt index 35e5958d33acb..7ea797fa926c1 100644 --- a/railties/lib/rails/generators/rails/app/templates/github/ci.yml.tt +++ b/railties/lib/rails/generators/rails/app/templates/github/ci.yml.tt @@ -12,7 +12,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up Ruby uses: ruby/setup-ruby@v1 @@ -32,7 +32,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up Ruby uses: ruby/setup-ruby@v1 @@ -50,7 +50,7 @@ jobs: RUBOCOP_CACHE_ROOT: tmp/rubocop steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up Ruby uses: ruby/setup-ruby@v1 @@ -117,7 +117,7 @@ jobs: <%- end -%> - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up Ruby uses: ruby/setup-ruby@v1 @@ -190,7 +190,7 @@ jobs: <%- end -%> - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up Ruby uses: ruby/setup-ruby@v1 diff --git a/railties/lib/rails/generators/rails/plugin/templates/github/ci.yml.tt b/railties/lib/rails/generators/rails/plugin/templates/github/ci.yml.tt index ea293d65061fe..72953eb817e70 100644 --- a/railties/lib/rails/generators/rails/plugin/templates/github/ci.yml.tt +++ b/railties/lib/rails/generators/rails/plugin/templates/github/ci.yml.tt @@ -14,7 +14,7 @@ jobs: RUBOCOP_CACHE_ROOT: tmp/rubocop steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up Ruby uses: ruby/setup-ruby@v1 @@ -77,7 +77,7 @@ jobs: <%- end -%> steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up Ruby uses: ruby/setup-ruby@v1 From f96072b47afb17db3fc7f5c5c94f8417a6fbbd93 Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Sun, 17 Aug 2025 11:48:05 +0200 Subject: [PATCH 0465/1075] Get rid of `typed: true` This should never have been merged. --- activesupport/lib/active_support/event_reporter.rb | 1 - activesupport/lib/active_support/event_reporter/encoders.rb | 1 - .../lib/active_support/testing/event_reporter_assertions.rb | 1 - activesupport/test/event_reporter_test.rb | 1 - activesupport/test/testing/event_reporter_assertions_test.rb | 1 - 5 files changed, 5 deletions(-) diff --git a/activesupport/lib/active_support/event_reporter.rb b/activesupport/lib/active_support/event_reporter.rb index c78c21005802e..24c765d0ea7e6 100644 --- a/activesupport/lib/active_support/event_reporter.rb +++ b/activesupport/lib/active_support/event_reporter.rb @@ -1,4 +1,3 @@ -# typed: true # frozen_string_literal: true require_relative "event_reporter/encoders" diff --git a/activesupport/lib/active_support/event_reporter/encoders.rb b/activesupport/lib/active_support/event_reporter/encoders.rb index d0eb44d6b4a47..9637bbf121659 100644 --- a/activesupport/lib/active_support/event_reporter/encoders.rb +++ b/activesupport/lib/active_support/event_reporter/encoders.rb @@ -1,4 +1,3 @@ -# typed: true # frozen_string_literal: true module ActiveSupport diff --git a/activesupport/lib/active_support/testing/event_reporter_assertions.rb b/activesupport/lib/active_support/testing/event_reporter_assertions.rb index bde113410bc47..e7792395089c7 100644 --- a/activesupport/lib/active_support/testing/event_reporter_assertions.rb +++ b/activesupport/lib/active_support/testing/event_reporter_assertions.rb @@ -1,4 +1,3 @@ -# typed: true # frozen_string_literal: true module ActiveSupport diff --git a/activesupport/test/event_reporter_test.rb b/activesupport/test/event_reporter_test.rb index 1a4cbe8640558..fd67aa8f6b4a5 100644 --- a/activesupport/test/event_reporter_test.rb +++ b/activesupport/test/event_reporter_test.rb @@ -1,4 +1,3 @@ -# typed: true # frozen_string_literal: true require_relative "abstract_unit" diff --git a/activesupport/test/testing/event_reporter_assertions_test.rb b/activesupport/test/testing/event_reporter_assertions_test.rb index 3ed4f2c74c01d..219cc84bfa2cc 100644 --- a/activesupport/test/testing/event_reporter_assertions_test.rb +++ b/activesupport/test/testing/event_reporter_assertions_test.rb @@ -1,4 +1,3 @@ -# typed: true # frozen_string_literal: true require "active_support/test_case" From 86aff6462ebc92d9a193fc08eba2f388f3b9f266 Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Sun, 17 Aug 2025 11:56:58 +0200 Subject: [PATCH 0466/1075] Get rid of ActiveSupport::EventReporter::Encoders module It's a needlessly deep namespace. With a shorter namespace there's no need for the `.encoder` lookup method thing. Just referencing a constant is fine. Also move encoders in their own files so we can test for msgpack presence at load time rather than on every `encode` call. --- activesupport/CHANGELOG.md | 2 +- .../lib/active_support/event_reporter.rb | 37 ++------ .../active_support/event_reporter/encoders.rb | 89 ------------------- .../event_reporter/json_encoder.rb | 51 +++++++++++ .../event_reporter/message_pack_encoder.rb | 27 ++++++ activesupport/test/event_reporter_test.rb | 31 +------ 6 files changed, 90 insertions(+), 147 deletions(-) delete mode 100644 activesupport/lib/active_support/event_reporter/encoders.rb create mode 100644 activesupport/lib/active_support/event_reporter/json_encoder.rb create mode 100644 activesupport/lib/active_support/event_reporter/message_pack_encoder.rb diff --git a/activesupport/CHANGELOG.md b/activesupport/CHANGELOG.md index 76c11daa60720..2870a3ef3c3d1 100644 --- a/activesupport/CHANGELOG.md +++ b/activesupport/CHANGELOG.md @@ -56,7 +56,7 @@ ```ruby class MySubscriber def emit(event) - encoded_event = ActiveSupport::EventReporter.encoder(:json).encode(event) + encoded_event = ActiveSupport::EventReporter::JSONEncoder.encode(event) StructuredLogExporter.export(encoded_event) end end diff --git a/activesupport/lib/active_support/event_reporter.rb b/activesupport/lib/active_support/event_reporter.rb index 24c765d0ea7e6..8c4a93acef6f3 100644 --- a/activesupport/lib/active_support/event_reporter.rb +++ b/activesupport/lib/active_support/event_reporter.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require_relative "event_reporter/encoders" - module ActiveSupport class TagStack # :nodoc: EMPTY_TAGS = {}.freeze @@ -134,7 +132,7 @@ def clear # class JSONLogSubscriber # def emit(event) # # event = { name: "UserCreatedEvent", payload: { UserCreatedEvent: # } } - # json_data = ActiveSupport::EventReporter.encoder(:json).encode(event) + # json_data = ActiveSupport::EventReporter::JSONEncoder.encode(event) # # => { # # "name": "UserCreatedEvent", # # "payload": { @@ -148,7 +146,7 @@ def clear # # class MessagePackSubscriber # def emit(event) - # msgpack_data = ActiveSupport::EventReporter.encoder(:msgpack).encode(event) + # msgpack_data = ActiveSupport::EventReporter::MessagePackEncoder.encode(event) # BatchExporter.export(msgpack_data) # end # end @@ -230,37 +228,16 @@ def clear # # payload: { id: 123 }, # # } class EventReporter + extend ActiveSupport::Autoload + + autoload :JSONEncoder + autoload :MessagePackEncoder + attr_reader :subscribers attr_accessor :raise_on_error - ENCODERS = { - json: Encoders::JSON, - msgpack: Encoders::MessagePack - }.freeze - class << self attr_accessor :context_store # :nodoc: - - # Lookup an encoder by name or symbol. - # - # ActiveSupport::EventReporter.encoder(:json) - # # => ActiveSupport::EventReporter::Encoders::JSON - # - # ActiveSupport::EventReporter.encoder("msgpack") - # # => ActiveSupport::EventReporter::Encoders::MessagePack - # - # ==== Arguments - # - # * +format+ - The encoder format as a symbol or string - # - # ==== Raises - # - # * +KeyError+ - If the encoder format is not found - def encoder(format) - ENCODERS.fetch(format.to_sym) do - raise KeyError, "Unknown encoder format: #{format.inspect}. Available formats: #{ENCODERS.keys.join(', ')}" - end - end end self.context_store = EventContext diff --git a/activesupport/lib/active_support/event_reporter/encoders.rb b/activesupport/lib/active_support/event_reporter/encoders.rb deleted file mode 100644 index 9637bbf121659..0000000000000 --- a/activesupport/lib/active_support/event_reporter/encoders.rb +++ /dev/null @@ -1,89 +0,0 @@ -# frozen_string_literal: true - -module ActiveSupport - class EventReporter - # = Event Encoders - # - # Default encoders for serializing structured events. These encoders can be used - # by subscribers to convert event data into various formats. - # - # Example usage in a subscriber: - # - # class LogSubscriber - # def emit(event) - # encoded_data = ActiveSupport::EventReporter::Encoders::JSON.encode(event) - # Rails.logger.info(encoded_data) - # end - # end - # - # Rails.event.subscribe(LogSubscriber) - module Encoders - # Base encoder class that other encoders can inherit from. - class Base - # Encodes an event hash into a serialized format. - # - # @param event [Hash] The event hash containing name, payload, tags, context, timestamp, and source_location - # @return [String] The encoded event data - def self.encode(event) - raise NotImplementedError, "Subclasses must implement #encode" - end - end - - # JSON encoder for serializing events to JSON format. - # - # event = { name: "user_created", payload: { id: 123 }, tags: { api: true } } - # ActiveSupport::EventReporter::Encoders::JSON.encode(event) - # # => { - # # "name": "user_created", - # # "payload": { - # # "id": 123 - # # }, - # # "tags": { - # # "api": true - # # }, - # # "context": {} - # # } - # - # Schematized events and tags MUST respond to #to_h to be serialized. - # - # event = { name: "UserCreatedEvent", payload: #, tags: { "GraphqlTag": # } } - # ActiveSupport::EventReporter::Encoders::JSON.encode(event) - # # => { - # # "name": "UserCreatedEvent", - # # "payload": { - # # "id": 123 - # # }, - # # "tags": { - # # "GraphqlTag": { - # # "operation_name": "user_created", - # # "operation_type": "mutation" - # # } - # # }, - # # "context": {} - # # } - class JSON < Base - def self.encode(event) - event[:payload] = event[:payload].to_h - event[:tags] = event[:tags].transform_values do |value| - value.respond_to?(:to_h) ? value.to_h : value - end - ::JSON.dump(event) - end - end - - # EventReporter encoder for serializing events to MessagePack format. - class MessagePack < Base - def self.encode(event) - require "msgpack" - event[:payload] = event[:payload].to_h - event[:tags] = event[:tags].transform_values do |value| - value.respond_to?(:to_h) ? value.to_h : value - end - ::MessagePack.pack(event) - rescue LoadError - raise LoadError, "msgpack gem is required for MessagePack encoding. Add 'gem \"msgpack\"' to your Gemfile." - end - end - end - end -end diff --git a/activesupport/lib/active_support/event_reporter/json_encoder.rb b/activesupport/lib/active_support/event_reporter/json_encoder.rb new file mode 100644 index 0000000000000..0d750619c5309 --- /dev/null +++ b/activesupport/lib/active_support/event_reporter/json_encoder.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require "json" + +module ActiveSupport + class EventReporter + # JSON encoder for serializing events to JSON format. + # + # event = { name: "user_created", payload: { id: 123 }, tags: { api: true } } + # ActiveSupport::EventReporter::JSONEncoder.encode(event) + # # => { + # # "name": "user_created", + # # "payload": { + # # "id": 123 + # # }, + # # "tags": { + # # "api": true + # # }, + # # "context": {} + # # } + # + # Schematized events and tags MUST respond to #to_h to be serialized. + # + # event = { name: "UserCreatedEvent", payload: #, tags: { "GraphqlTag": # } } + # ActiveSupport::EventReporter::JSONEncoder.encode(event) + # # => { + # # "name": "UserCreatedEvent", + # # "payload": { + # # "id": 123 + # # }, + # # "tags": { + # # "GraphqlTag": { + # # "operation_name": "user_created", + # # "operation_type": "mutation" + # # } + # # }, + # # "context": {} + # # } + module JSONEncoder + class << self + def encode(event) + event[:payload] = event[:payload].to_h + event[:tags] = event[:tags].transform_values do |value| + value.respond_to?(:to_h) ? value.to_h : value + end + ::JSON.generate(event) + end + end + end + end +end diff --git a/activesupport/lib/active_support/event_reporter/message_pack_encoder.rb b/activesupport/lib/active_support/event_reporter/message_pack_encoder.rb new file mode 100644 index 0000000000000..5da108e7b6ea8 --- /dev/null +++ b/activesupport/lib/active_support/event_reporter/message_pack_encoder.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +begin + gem "msgpack", ">= 1.7.0" + require "msgpack" +rescue LoadError => error + warn "ActiveSupport::EventReporter::MessagePackEncoder requires the msgpack gem, version 1.7.0 or later. " \ + "Please add it to your Gemfile: `gem \"msgpack\", \">= 1.7.0\"`" + raise error +end + +module ActiveSupport + class EventReporter + # EventReporter encoder for serializing events to MessagePack format. + module MessagePackEncoder + class << self + def encode(event) + event[:payload] = event[:payload].to_h + event[:tags] = event[:tags].transform_values do |value| + value.respond_to?(:to_h) ? value.to_h : value + end + ::MessagePack.pack(event) + end + end + end + end +end diff --git a/activesupport/test/event_reporter_test.rb b/activesupport/test/event_reporter_test.rb index fd67aa8f6b4a5..ed2b6392829a7 100644 --- a/activesupport/test/event_reporter_test.rb +++ b/activesupport/test/event_reporter_test.rb @@ -573,31 +573,8 @@ def to_h } end - test "looking up encoder by symbol" do - assert_equal EventReporter::Encoders::JSON, EventReporter.encoder(:json) - assert_equal EventReporter::Encoders::MessagePack, EventReporter.encoder(:msgpack) - end - - test "looking up encoder by string" do - assert_equal EventReporter::Encoders::JSON, EventReporter.encoder("json") - assert_equal EventReporter::Encoders::MessagePack, EventReporter.encoder("msgpack") - end - - test "looking up nonexistant encoder raises KeyError" do - error = assert_raises(KeyError) do - EventReporter.encoder(:unknown) - end - assert_equal "Unknown encoder format: :unknown. Available formats: json, msgpack", error.message - end - - test "Base encoder raises NotImplementedError" do - assert_raises(NotImplementedError) do - EventReporter::Encoders::Base.encode(@event) - end - end - test "JSON encoder encodes event to JSON" do - json_string = EventReporter::Encoders::JSON.encode(@event) + json_string = EventReporter::JSONEncoder.encode(@event) parsed = ::JSON.parse(json_string) assert_equal "test_event", parsed["name"] @@ -611,7 +588,7 @@ def to_h test "JSON encoder serializes event objects and object tags as hashes" do @event[:payload] = TestEvent.new("value") @event[:tags] = { "HttpRequestTag": HttpRequestTag.new("GET", 200) } - json_string = EventReporter::Encoders::JSON.encode(@event) + json_string = EventReporter::JSONEncoder.encode(@event) parsed = ::JSON.parse(json_string) assert_equal "value", parsed["payload"]["data"] @@ -626,7 +603,7 @@ def to_h skip "msgpack gem not available" end - msgpack_data = EventReporter::Encoders::MessagePack.encode(@event) + msgpack_data = EventReporter::MessagePackEncoder.encode(@event) parsed = ::MessagePack.unpack(msgpack_data) assert_equal "test_event", parsed["name"] @@ -640,7 +617,7 @@ def to_h test "MessagePack encoder serializes event objects and object tags as hashes" do @event[:payload] = TestEvent.new("value") @event[:tags] = { "HttpRequestTag": HttpRequestTag.new("GET", 200) } - msgpack_data = EventReporter::Encoders::MessagePack.encode(@event) + msgpack_data = EventReporter::MessagePackEncoder.encode(@event) parsed = ::MessagePack.unpack(msgpack_data) assert_equal "value", parsed["payload"]["data"] From 5e1647343dda9d78101b625c2d556eab689fe2e6 Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Sun, 17 Aug 2025 12:01:41 +0200 Subject: [PATCH 0467/1075] msgpack is in Rails gemfile, it's always available. --- activesupport/test/event_reporter_test.rb | 6 ------ 1 file changed, 6 deletions(-) diff --git a/activesupport/test/event_reporter_test.rb b/activesupport/test/event_reporter_test.rb index ed2b6392829a7..b476fef6af6b2 100644 --- a/activesupport/test/event_reporter_test.rb +++ b/activesupport/test/event_reporter_test.rb @@ -597,12 +597,6 @@ def to_h end test "MessagePack encoder encodes event to MessagePack" do - begin - require "msgpack" - rescue LoadError - skip "msgpack gem not available" - end - msgpack_data = EventReporter::MessagePackEncoder.encode(@event) parsed = ::MessagePack.unpack(msgpack_data) From d13e88530964d1e1a69b924194e74f08dc48992e Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Sun, 17 Aug 2025 12:14:16 +0200 Subject: [PATCH 0468/1075] ActiveSupport::ErrorReporter: report errors rather than emit Ruby warnings Ruby warnings end up on stderr, or at best in log files nobody looks at. We now have the standard error reporting interface to report handled errors. --- .../lib/active_support/event_reporter.rb | 17 ++++++------ activesupport/test/event_reporter_test.rb | 26 +++++-------------- 2 files changed, 16 insertions(+), 27 deletions(-) diff --git a/activesupport/lib/active_support/event_reporter.rb b/activesupport/lib/active_support/event_reporter.rb index 8c4a93acef6f3..9dfbf01d0b5cb 100644 --- a/activesupport/lib/active_support/event_reporter.rb +++ b/activesupport/lib/active_support/event_reporter.rb @@ -233,8 +233,8 @@ class EventReporter autoload :JSONEncoder autoload :MessagePackEncoder + attr_writer :raise_on_error # :nodoc: attr_reader :subscribers - attr_accessor :raise_on_error class << self attr_accessor :context_store # :nodoc: @@ -335,13 +335,10 @@ def notify(name_or_object, payload = nil, caller_depth: 1, **kwargs) subscriber.emit(event) rescue => subscriber_error - if raise_on_error + if raise_on_error? raise else - warn(<<~MESSAGE) - Event reporter subscriber #{subscriber.class.name} raised an error on #emit: #{subscriber_error.message} - #{subscriber_error.backtrace&.join("\n")} - MESSAGE + ActiveSupport.error_reporter.report(subscriber_error, handled: true) end end end @@ -472,6 +469,10 @@ def context end private + def raise_on_error? + @raise_on_error + end + def context_store self.class.context_store end @@ -506,10 +507,10 @@ def handle_unexpected_args(name_or_object, payload, kwargs) Received: #{name_or_object.inspect}, #{payload.inspect}, #{kwargs.inspect} MESSAGE - if raise_on_error + if raise_on_error? raise ArgumentError, message else - warn(message) + ActiveSupport.error_reporter.report(ArgumentError.new(message), handled: true) end end end diff --git a/activesupport/test/event_reporter_test.rb b/activesupport/test/event_reporter_test.rb index b476fef6af6b2..7515701e3e458 100644 --- a/activesupport/test/event_reporter_test.rb +++ b/activesupport/test/event_reporter_test.rb @@ -187,41 +187,29 @@ def emit(event) end test "#notify with event object and kwargs warns when raise_on_error is false" do - previous_raise_on_error = @reporter.raise_on_error - @reporter.raise_on_error = false + @reporter = EventReporter.new(@subscriber, raise_on_error: false) event = TestEvent.new("value") - _out, err = capture_io do - assert_called_with(@subscriber, :emit, [ - event_matcher(name: TestEvent.name, payload: event) - ]) do + error_report = assert_error_reported do + assert_called_with(@subscriber, :emit, [event_matcher(name: TestEvent.name, payload: event)]) do @reporter.notify(event, extra: "arg") - rescue RailsStrictWarnings::WarningError => _e - # Expected warning end end + err = error_report.error.message assert_match(/Rails.event.notify accepts either an event object, a payload hash, or keyword arguments/, err) - ensure - @reporter.raise_on_error = previous_raise_on_error end test "#notify warns about subscriber errors when raise_on_error is false" do - previous_raise_on_error = @reporter.raise_on_error - @reporter.raise_on_error = false + @reporter = EventReporter.new(@subscriber, raise_on_error: false) @reporter.subscribe(ErrorSubscriber.new) - _out, err = capture_io do + error_report = assert_error_reported do @reporter.notify(:test_event) - rescue RailsStrictWarnings::WarningError => _e - # Expected warning end - - assert_match(/Event reporter subscriber #{ErrorSubscriber.name} raised an error on #emit: Uh oh!/, err) - ensure - @reporter.raise_on_error = previous_raise_on_error + assert_equal "Uh oh!", error_report.error.message end test "#notify raises subscriber errors when raise_on_error is true" do From 6ead5c9b345cb8eb1a9140b0aceb0f47326317a5 Mon Sep 17 00:00:00 2001 From: Brendan Weibrecht Date: Mon, 18 Aug 2025 13:00:26 +1000 Subject: [PATCH 0469/1075] Remove PostgreSQL `\restrict` lines from structure.sql Resolves https://github.com/rails/rails/issues/55509 Fixes removal of `pg_dump`'s versioning comments. By removing all these lines, the generated `structure.sql` can again be consistent between runs of `rails db:schema:dump` on the latest versions of PostgreSQL --- .../tasks/postgresql_database_tasks.rb | 1 + .../postgresql/postgresql_rake_test.rb | 36 +++++++++++++++++-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/activerecord/lib/active_record/tasks/postgresql_database_tasks.rb b/activerecord/lib/active_record/tasks/postgresql_database_tasks.rb index 612ce6f4db644..a40c6d3e6a23c 100644 --- a/activerecord/lib/active_record/tasks/postgresql_database_tasks.rb +++ b/activerecord/lib/active_record/tasks/postgresql_database_tasks.rb @@ -99,6 +99,7 @@ def remove_sql_header_comments(filename) tempfile = Tempfile.open("uncommented_structure.sql") begin File.foreach(filename) do |line| + next if line.start_with?("\\restrict ", "\\unrestrict ") unless removing_comments && (line.start_with?(SQL_COMMENT_BEGIN) || line.blank?) tempfile << line removing_comments = false diff --git a/activerecord/test/cases/adapters/postgresql/postgresql_rake_test.rb b/activerecord/test/cases/adapters/postgresql/postgresql_rake_test.rb index 40d38ac8b324a..4a7be469d7511 100644 --- a/activerecord/test/cases/adapters/postgresql/postgresql_rake_test.rb +++ b/activerecord/test/cases/adapters/postgresql/postgresql_rake_test.rb @@ -345,10 +345,42 @@ def test_structure_dump def test_structure_dump_header_comments_removed Kernel.stub(:system, true) do - File.write(@filename, "-- header comment\n\n-- more header comment\n statement \n-- lower comment\n") + raw_dump_sql = <<~SQL + -- header comment + + -- more header comment + statement + -- lower comment + SQL + expected_dump_sql = <<~SQL + statement + -- lower comment + SQL + File.write(@filename, raw_dump_sql) ActiveRecord::Tasks::DatabaseTasks.structure_dump(@configuration, @filename) + assert_equal expected_dump_sql, File.readlines(@filename).first(2).join + end + end - assert_equal [" statement \n", "-- lower comment\n"], File.readlines(@filename).first(2) + def test_structure_dump_header_comments_with_restrict_commands_removed + Kernel.stub(:system, true) do + raw_dump_sql = <<~SQL + \\restrict pbgv1pF8SxQK6cuT7hwDi21uDYr8wpxKJ3wlLa9Zk5EIO1xBiu84SJQU8fL22PT + + -- header comment + + -- more header comment + statement + -- lower comment + \\unrestrict pbgv1pF8SxQK6cuT7hwDi21uDYr8wpxKJ3wlLa9Zk5EIO1xBiu84SJQU8fL22PT + SQL + expected_dump_sql = <<~SQL + statement + -- lower comment + SQL + File.write(@filename, raw_dump_sql) + ActiveRecord::Tasks::DatabaseTasks.structure_dump(@configuration, @filename) + assert_equal expected_dump_sql, File.readlines(@filename).first(2).join end end From 430cff888c248eb45258c74953226edd3b4bae50 Mon Sep 17 00:00:00 2001 From: Ridhwana Date: Mon, 18 Aug 2025 22:04:05 +0200 Subject: [PATCH 0470/1075] [RF-DOCS] Rails Plugin (#55203) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Motivation / Background The Rails Plugins Guide is a WIP guide. In order to remove the WIP tag, we've updated the guide to be more informative. Following this PR, we'll update the Engines guide to reduce any overlap. ### Detail In this Pull Request, we do the ffg: - Update the introduction to be more standard. i.e. outline what the guide covers and then introduce the topic. - Update the Yaffle example to an API Enhancement Plugin to be more developer relevant for the guides. - Switch from a test driven approach to a test-later approach, which aligns with other guides and will make it easier to follow. - Show the created files/dirs when generating a plugin, and explain the dummy application. - Add a word of caution about extending core classes. - Add a brief explanation of `ActiveSupport::Concern` with API docs link - Add an advanced section: Advanced Integration: Using Railties. Co-authored-by: bhumi1102 Co-authored-by: Petrik de Heus Co-authored-by: Alex Kitchens Co-authored-by: Rafael Mendonça França Co-authored-by: Adrian --- .mdlrc.rb | 1 + guides/source/documents.yaml | 1 - guides/source/plugins.md | 807 +++++++++++++++++++++++------------ 3 files changed, 530 insertions(+), 279 deletions(-) diff --git a/.mdlrc.rb b/.mdlrc.rb index 78d01e5ccde72..2788e6a1ffcbd 100644 --- a/.mdlrc.rb +++ b/.mdlrc.rb @@ -11,6 +11,7 @@ exclude_rule "MD014" exclude_rule "MD024" exclude_rule "MD026" +exclude_rule "MD032" exclude_rule "MD033" exclude_rule "MD034" exclude_rule "MD036" diff --git a/guides/source/documents.yaml b/guides/source/documents.yaml index 395d453a92ee4..9913750419c38 100644 --- a/guides/source/documents.yaml +++ b/guides/source/documents.yaml @@ -271,7 +271,6 @@ documents: - name: The Basics of Creating Rails Plugins - work_in_progress: true url: plugins.html description: This guide covers how to build a plugin to extend the functionality of Rails. - diff --git a/guides/source/plugins.md b/guides/source/plugins.md index 333e1bfd24ac4..a235f62e6b7e2 100644 --- a/guides/source/plugins.md +++ b/guides/source/plugins.md @@ -3,62 +3,187 @@ The Basics of Creating Rails Plugins ==================================== -A Rails plugin is either an extension or a modification of the core framework. Plugins provide: - -* A way for developers to share bleeding-edge ideas without hurting the stable code base. -* A segmented architecture so that units of code can be fixed or updated on their own release schedule. -* An outlet for the core developers so that they don't have to include every cool new feature under the sun. +This guide is for developers who want to create a Rails plugin, in order to +extend or modify the behavior of a Rails application. After reading this guide, you will know: +* What Rails plugins are and when to use them. * How to create a plugin from scratch. -* How to write and run tests for the plugin. +* How to extend core Ruby classes. +* How to add methods to `ApplicationRecord`. +* How to publish your plugin to RubyGems. -This guide describes how to build a test-driven plugin that will: +-------------------------------------------------------------------------------- -* Extend core Ruby classes like Hash and String. -* Add methods to `ApplicationRecord` in the tradition of the `acts_as` plugins. -* Give you information about where to put generators in your plugin. +What are Plugins? +----------------- + +A Rails plugin is a packaged extension that adds functionality to a Rails +application. Plugins serve several purposes: + +* They offer a way for developers to experiment with new ideas without affecting + the stability of the core codebase. +* They support a modular architecture, allowing features to be maintained, + updated, or released independently. +* They give teams an outlet for introducing powerful features without needing to + include everything directly into the framework. + +At a technical level, a plugin is a Ruby gem that’s designed to work inside a +Rails application. It often uses a +[Railtie](https://api.rubyonrails.org/classes/Rails/Railtie.html) to hook into the +Rails boot process, allowing it to extend or modify the framework's behavior in +a structured way. A Railtie is the most +basic integration point for extending Rails — it’s typically used when your +plugin needs to add configuration, rake tasks, or initializer code, but doesn’t +expose any controllers, views, or models. + +NOTE: +An [Engine](engines.html) is a more advanced type of plugin that behaves like a mini Rails +application. It can include its own routes, controllers, views, and even assets. +While all engines are plugins, not all plugins are engines. The main difference lies +in scope: plugins are typically used for smaller customizations or shared +behavior across apps, whereas engines provide more fully-featured components +with their own routes, models, and views. + + +Generator Options +------------------ -For the purpose of this guide pretend for a moment that you are an avid bird watcher. -Your favorite bird is the Yaffle, and you want to create a plugin that allows other developers to share in the Yaffle -goodness. +Rails plugins are built as gems. They can be shared across different Rails +applications using [RubyGems](https://guides.rubygems.org/make-your-own-gem/) +and [Bundler](https://bundler.io/guides/creating_gem.html) if desired. --------------------------------------------------------------------------------- +The `rails plugin new` command supports several options that determine what type +of plugin structure is generated. -Setup ------ +The **Basic Plugin** (default), without any arguments, generates a minimal +plugin structure suitable for simple extensions like core class methods or +utility functions. + +```bash +$ rails plugin new api_boost +``` + +We'll use the basic plugin generator for this guide. There are two options, +`--full` and `--mountable`, which are covered in the [Rails Engines +guide](engines.html). + +The **Full Plugin** (`--full`) option creates a more complete plugin structure +that includes an `app` directory tree (models, views, controllers), a +`config/routes.rb` file, and an Engine class at `lib/api_boost/engine.rb`. + +```bash +$ rails plugin new api_boost --full +``` -Currently, Rails plugins are built as gems, _gemified plugins_. They can be shared across -different Rails applications using RubyGems and Bundler if desired. +Use `--full` when your plugin needs its own models, controllers, or views but +doesn't require namespace isolation. -### Generate a Gemified Plugin +The **Mountable Engine** (`--mountable`) option creates a fully isolated, +mountable engine that includes everything from `--full` plus: -Rails ships with a `rails plugin new` command which creates a -skeleton for developing any kind of Rails extension with the ability -to run integration tests using a dummy Rails application. Create your -plugin with the command: +- Namespace isolation (`ApiBoost::` prefix for all classes) +- Isolated routing (`ApiBoost::Engine.routes.draw`) +- Asset manifest files +- Namespaced `ApplicationController` and `ApplicationHelper` +- Automatic mounting in the dummy app for testing ```bash -$ rails plugin new yaffle +$ rails plugin new api_boost --mountable ``` +Use `--mountable` when building a self-contained feature that could work as a +separate application. + +For more information about engines, see the [Getting Started with Engines +guide](engines.html). + +Below is some guidance on choosing the right option: + +- **Basic plugin**: Simple utilities, core class extensions, or small helper + methods +- **`--full` plugin**: Complex functionality that needs models/controllers but + shares the host app's namespace +- **`--mountable` engine**: Self-contained features like admin panels, blogs, or + API modules + See usage and options by asking for help: ```bash $ rails plugin new --help ``` -Testing Your Newly Generated Plugin ------------------------------------ +Setup +------ + +For the purpose of this guide, imagine you're building APIs and want to create a +plugin that adds common API functionality like request throttling, response +caching, and automatic API documentation. You'll create a plugin called +"ApiBoost" that can enhance any Rails API application. + +### Generate the Plugin + +Create a basic plugin with the command: + +```bash +$ rails plugin new api_boost +``` + +This will create the ApiBoost plugin in a directory named `api_boost`. Let's +examine what was generated: -Navigate to the directory that contains the plugin, and edit `yaffle.gemspec` to -replace any lines that have `TODO` values: +``` +api_boost/ +├── api_boost.gemspec +├── Gemfile +├── lib/ +│ ├── api_boost/ +│ │ └── version.rb +│ ├── api_boost.rb +│ └── tasks/ +│ └── api_boost_tasks.rake +├── test/ +│ ├── dummy/ +│ │ ├── app/ +│ │ ├── bin/ +│ │ ├── config/ +│ │ ├── db/ +│ │ ├── public/ +│ │ └── ... (full Rails application) +│ ├── integration/ +│ └── test_helper.rb +├── MIT-LICENSE +└── README.md +``` + +**The `lib` directory** contains your plugin's source code: + +- `lib/api_boost.rb` is the main entry point for your plugin +- `lib/api_boost/` contains modules and classes for your plugin functionality +- `lib/tasks/` contains any Rake tasks your plugin provides + +**The `test/dummy` directory** contains a complete Rails application that's used +for testing your plugin. This dummy application: + +- Loads your plugin automatically through the Gemfile +- Provides a Rails environment to test your plugin's integration +- Includes generators, models, controllers, and views as needed for testing +- Can be used interactively with `rails console` and `rails server` + +**The Gemspec file** (`api_boost.gemspec`) defines your gem's metadata, +dependencies, and the files to include when packaging. + + +### Set Up the Plugin + +Navigate to the directory that contains the plugin, and edit `api_boost.gemspec` +to replace any lines that have `TODO` values: ```ruby spec.homepage = "http://example.com" -spec.summary = "Summary of Yaffle." -spec.description = "Description of Yaffle." +spec.summary = "Enhance your API endpoints" +spec.description = "Adds common API functionality like request throttling, response caching, and automatic API documentation." ... @@ -68,14 +193,20 @@ spec.metadata["changelog_uri"] = "http://example.com" Then run the `bundle install` command. -After that, set up your testing database by navigating to the `test/dummy` directory and running the following command: +After that, set up your testing database by navigating to the `test/dummy` +directory and running the following command: ```bash $ cd test/dummy $ bin/rails db:create ``` -Once the database is created, return to the plugin's root directory (`cd ../..`). +The dummy application works just like any Rails application - you can generate +models, run migrations, start the server, or open a console to test the plugin's +functionality as you develop it. + +Once the database is created, return to the plugin's root directory (`cd +../..`). Now you can run the tests using the `bin/test` command, and you should see: @@ -85,382 +216,505 @@ $ bin/test 1 runs, 1 assertions, 0 failures, 0 errors, 0 skips ``` -This will tell you that everything got generated properly, and you are ready to start adding functionality. +This will tell you that everything got generated properly, and you are ready to +start adding functionality. Extending Core Classes ---------------------- -This section will explain how to add a method to String that will be available anywhere in your Rails application. +This section will explain how to add a method to +[Integer](https://docs.ruby-lang.org/en/master/Integer.html) that will be +available anywhere in your Rails application. + +WARNING: Before proceeding, it's important to understand that extending core +classes (like String, Array, Hash, etc.) should be used sparingly, if at all. +Core class extensions can be brittle, dangerous, and are often +unnecessary.

They can:
+- Cause naming conflicts when multiple gems extend the same class with the same + method name
+- Break unexpectedly when Ruby or Rails updates change core class behavior
+- Make debugging difficult because it's not obvious where methods come from
+- Create coupling issues between your plugin and other code

Better +alternatives to consider:
+- Create utility modules or helper classes instead
+- Use composition over monkey patching
+- Implement functionality as instance methods on your own classes

For +more details on why core class extensions can be problematic, see [The Case +Against Monkey +Patching](https://shopify.engineering/the-case-against-monkey-patching). +

That said, understanding how core class extensions work is valuable. +The example below demonstrates the technique, but they should be used sparingly. + +In this example you will add a method to Integer named `requests_per_hour`. + +In `lib/api_boost.rb`, add `require "api_boost/core_ext"`: + +```ruby +# api_boost/lib/api_boost.rb -In this example you will add a method to String named `to_squawk`. To begin, create a new test file with a few assertions: +require "api_boost/version" +require "api_boost/railtie" +require "api_boost/core_ext" + +module ApiBoost + # Your code goes here... +end +``` + +Create the `core_ext.rb` file and add a method to Integer to define a RateLimit +that could define `10.requests_per_hour`, similar to `10.hours` that returns a +Time. ```ruby -# yaffle/test/core_ext_test.rb +# api_boost/lib/api_boost/core_ext.rb -require "test_helper" +ApiBoost::RateLimit = Data.define(:requests, :per) -class CoreExtTest < ActiveSupport::TestCase - def test_to_squawk_prepends_the_word_squawk - assert_equal "squawk! Hello World", "Hello World".to_squawk +class Integer + def requests_per_hour + ApiBoost::RateLimit.new(self, :hour) end end ``` -Run `bin/test` to run the test. This test should fail because we haven't implemented the `to_squawk` method: +To see this in action, change to the `test/dummy` directory, start `bin/rails +console`, and test the API response formatting: ```bash -$ bin/test -E +$ cd test/dummy +$ bin/rails console +``` + +```irb +irb> 10.requests_per_hour +=> # +``` -Error: -CoreExtTest#test_to_squawk_prepends_the_word_squawk: -NoMethodError: undefined method `to_squawk' for "Hello World":String +The dummy application automatically loads your plugin, so any extensions you add +are immediately available for testing. +Add an "acts_as" Method to Active Record +---------------------------------------- -bin/test /path/to/yaffle/test/core_ext_test.rb:4 +A common pattern in plugins is to add a method called `acts_as_something` to +models. In this case, you want to write a method called `acts_as_api_resource` +that adds API-specific functionality to your Active Record models. -. +Let’s say you’re building an API, and you want to keep track of the last time a +resource (like a `Product`) was accessed via that API. You might want to use +that timestamp to: -Finished in 0.003358s, 595.6483 runs/s, 297.8242 assertions/s. -2 runs, 1 assertions, 0 failures, 1 errors, 0 skips -``` +* throttle requests +* show “last active” times in your admin panel +* prioritize stale records for syncing -Great - now you are ready to start development. +Instead of writing this logic in every model, you can use a shared plugin. The +`acts_as_api_resource` method adds this functionality to any model, letting you +track API activity by updating a timestamp field. -In `lib/yaffle.rb`, add `require "yaffle/core_ext"`: +To begin, set up your files so that you have: ```ruby -# yaffle/lib/yaffle.rb +# api_boost/lib/api_boost.rb -require "yaffle/version" -require "yaffle/railtie" -require "yaffle/core_ext" +require "api_boost/version" +require "api_boost/railtie" +require "api_boost/core_ext" +require "api_boost/acts_as_api_resource" -module Yaffle +module ApiBoost # Your code goes here... end ``` -Finally, create the `core_ext.rb` file and add the `to_squawk` method: - ```ruby -# yaffle/lib/yaffle/core_ext.rb +# api_boost/lib/api_boost/acts_as_api_resource.rb -class String - def to_squawk - "squawk! #{self}".strip +module ApiBoost + module ActsAsApiResource + extend ActiveSupport::Concern + + class_methods do + def acts_as_api_resource(api_timestamp_field: :last_requested_at) + # Create a class-level setting that stores which field to use for the API timestamp. + cattr_accessor :api_timestamp_field, default: api_timestamp_field.to_s + end + end end end ``` -To test that your method does what it says it does, run the unit tests with `bin/test` from your plugin directory. +The code above uses `ActiveSupport::Concern` to simplify including modules with +both class and instance methods. Methods in the `class_methods` block become +class methods when the module is included. For more details, see the +[ActiveSupport::Concern API +documentation](https://api.rubyonrails.org/classes/ActiveSupport/Concern.html). -```bash -$ bin/test -... -2 runs, 2 assertions, 0 failures, 0 errors, 0 skips -``` +### Add a Class Method -To see this in action, change to the `test/dummy` directory, start `bin/rails console`, and commence squawking: +By default, this plugin expects your model to have a column named +`last_requested_at`. However, since that column name might already be used for +something else, the plugin lets you customize it. You can override the default +by passing a different column name with the `api_timestamp_field:` option. +Internally, this value is stored in a class-level setting called +`api_timestamp_field`, which the plugin uses when updating the timestamp. -```irb -irb> "Hello World".to_squawk -=> "squawk! Hello World" -``` +For example, if you want to use `last_api_call` instead of `last_requested_at` as +the column name, you can do the following: -Add an "acts_as" Method to Active Record ----------------------------------------- +First, generate some models in your "dummy" Rails application to test this +functionality. Run the following commands from the `test/dummy` directory: -A common pattern in plugins is to add a method called `acts_as_something` to models. In this case, you -want to write a method called `acts_as_yaffle` that adds a `squawk` method to your Active Record models. +```bash +$ cd test/dummy +$ bin/rails generate model Product last_requested_at:datetime last_api_call:datetime +$ bin/rails db:migrate +``` -To begin, set up your files so that you have: +Now update the Product model so that it acts like an API resource: ```ruby -# yaffle/test/acts_as_yaffle_test.rb +# test/dummy/app/models/product.rb -require "test_helper" - -class ActsAsYaffleTest < ActiveSupport::TestCase +class Product < ApplicationRecord + acts_as_api_resource api_timestamp_field: :last_api_call end ``` +To make the plugin available to all models, include the module in +`ApplicationRecord` (we'll look at doing this automatically later): + ```ruby -# yaffle/lib/yaffle.rb +# test/dummy/app/models/application_record.rb -require "yaffle/version" -require "yaffle/railtie" -require "yaffle/core_ext" -require "yaffle/acts_as_yaffle" +class ApplicationRecord < ActiveRecord::Base + include ApiBoost::ActsAsApiResource -module Yaffle - # Your code goes here... + self.abstract_class = true end ``` -```ruby -# yaffle/lib/yaffle/acts_as_yaffle.rb +Now you can test this functionality in the Rails console: -module Yaffle - module ActsAsYaffle - end -end +```irb +irb> Product.api_timestamp_field +=> "last_api_call" ``` -### Add a Class Method +### Add an Instance Method -This plugin will expect that you've added a method to your model named `last_squawk`. However, the -plugin users might have already defined a method on their model named `last_squawk` that they use -for something else. This plugin will allow the name to be changed by adding a class method called `yaffle_text_field`. +This plugin adds an instance method called `track_api_request` to any Active +Record model that calls `acts_as_api_resource`. This method sets the value of +the configured timestamp field to the current time (or a custom time if +provided), allowing you to track when an API request was made. -To start out, write a failing test that shows the behavior you'd like: +To add this behavior, update `acts_as_api_resource.rb`: ```ruby -# yaffle/test/acts_as_yaffle_test.rb +# api_boost/lib/api_boost/acts_as_api_resource.rb -require "test_helper" +module ApiBoost + module ActsAsApiResource + extend ActiveSupport::Concern -class ActsAsYaffleTest < ActiveSupport::TestCase - def test_a_hickwalls_yaffle_text_field_should_be_last_squawk - assert_equal "last_squawk", Hickwall.yaffle_text_field - end + class_methods do + def acts_as_api_resource(options = {}) + cattr_accessor :api_timestamp_field, + default: (options[:api_timestamp_field] || :last_requested_at).to_s + end + end - def test_a_wickwalls_yaffle_text_field_should_be_last_tweet - assert_equal "last_tweet", Wickwall.yaffle_text_field + def track_api_request(timestamp = Time.current) + write_attribute(self.class.api_timestamp_field, timestamp) + end end end ``` -When you run `bin/test`, you should see the following: - -```bash -$ bin/test -# Running: - -..E - -Error: -ActsAsYaffleTest#test_a_wickwalls_yaffle_text_field_should_be_last_tweet: -NameError: uninitialized constant ActsAsYaffleTest::Wickwall +NOTE: The use of `write_attribute` above to write to the field in model is just +one example of how a plugin can interact with the model, and will not always be +the right method to use. For example, you might prefer using `send`, which calls +the setter method +```ruby +send("#{self.class.api_timestamp_field}=", timestamp) +``` -bin/test /path/to/yaffle/test/acts_as_yaffle_test.rb:8 +Now you can test the functionality in the Rails console: -E +```irb +irb> product = Product.new +irb> product.track_api_request +irb> product.last_api_call +=> 2025-06-01 10:31:15 UTC +``` -Error: -ActsAsYaffleTest#test_a_hickwalls_yaffle_text_field_should_be_last_squawk: -NameError: uninitialized constant ActsAsYaffleTest::Hickwall +Advanced Integration: Using Railties +------------------------------------ +The plugin we've built so far works great for basic functionality. However, if +the plugin needs to integrate more deeply with Rails' framework, you'll want to +use a [Railtie](https://api.rubyonrails.org/classes/Rails/Railtie.html). -bin/test /path/to/yaffle/test/acts_as_yaffle_test.rb:4 +A Railtie is required when your plugin needs to: +* Add configuration options accessible via `Rails.application.config` +* Automatically include modules in Rails classes without manual setup +* Provide Rake tasks to the host application +* Set up initializers that run during Rails boot +* Add middleware to the application stack +* Configure Rails generators +* Subscribe to `ActiveSupport::Notifications` +For simple plugins like ours that only extend core classes or add modules, a +Railtie isn't necessary. -Finished in 0.004812s, 831.2949 runs/s, 415.6475 assertions/s. -4 runs, 2 assertions, 0 failures, 2 errors, 0 skips -``` +### Configuration Options -This tells us that we don't have the necessary models (Hickwall and Wickwall) that we are trying to test. -We can easily generate these models in our "dummy" Rails application by running the following commands from the -`test/dummy` directory: +Let's say you want to make the default rate limit in your +`to_throttled_response` method configurable. First, create a Railtie: -```bash -$ cd test/dummy -$ bin/rails generate model Hickwall last_squawk:string -$ bin/rails generate model Wickwall last_squawk:string last_tweet:string -``` +```ruby +# api_boost/lib/api_boost/railtie.rb -Now you can create the necessary database tables in your testing database by navigating to your dummy app -and migrating the database. First, run: +module ApiBoost + class Railtie < Rails::Railtie + config.api_boost = ActiveSupport::OrderedOptions.new + config.api_boost.default_rate_limit = 60.requests_per_hour -```bash -$ cd test/dummy -$ bin/rails db:migrate + initializer "api_boost.configure" do |app| + ApiBoost.configuration = app.config.api_boost + end + end +end ``` -While you are here, change the Hickwall and Wickwall models so that they know that they are supposed to act -like yaffles. +Add a configuration module to your plugin: ```ruby -# test/dummy/app/models/hickwall.rb - -class Hickwall < ApplicationRecord - acts_as_yaffle -end -``` +# api_boost/lib/api_boost/configuration.rb -```ruby -# test/dummy/app/models/wickwall.rb +module ApiBoost + mattr_accessor :configuration, default: nil -class Wickwall < ApplicationRecord - acts_as_yaffle yaffle_text_field: :last_tweet + def self.configure + yield(configuration) if block_given? + end end ``` -We will also add code to define the `acts_as_yaffle` method. +Update your core extension to use the configuration: ```ruby -# yaffle/lib/yaffle/acts_as_yaffle.rb - -module Yaffle - module ActsAsYaffle - extend ActiveSupport::Concern - - class_methods do - def acts_as_yaffle(options = {}) +# api_boost/lib/api_boost/core_ext.rb + +module ApiBoost + module ActsAsApiResource + def to_throttled_json(rate_limit = ApiBoost.configuration.default_rate_limit) + limit_window = 1.send(rate_limit.per).ago.. + num_of_requests = self.class.where(self.class.api_timestamp_field => limit_window).count + if num_of_requests > rate_limit.requests + { error: "Rate limit reached" }.to_json + else + to_json end end end end ``` +Require the new files in your main plugin file: + ```ruby -# test/dummy/app/models/application_record.rb +# api_boost/lib/api_boost.rb -class ApplicationRecord < ActiveRecord::Base - include Yaffle::ActsAsYaffle +require "api_boost/version" +require "api_boost/configuration" +require "api_boost/railtie" +require "api_boost/core_ext" +require "api_boost/acts_as_api_resource" - self.abstract_class = true +module ApiBoost + # Your code goes here... end ``` -You can then return to the root directory (`cd ../..`) of your plugin and rerun the tests using `bin/test`. - -```bash -$ bin/test -# Running: +Now applications using your plugin can configure it: -.E - -Error: -ActsAsYaffleTest#test_a_hickwalls_yaffle_text_field_should_be_last_squawk: -NoMethodError: undefined method `yaffle_text_field' for # +```ruby +# config/application.rb +config.api_boost.default_rate_limit = "100 requests per hour" +``` +### Automatic Module Inclusion -bin/test /path/to/yaffle/test/acts_as_yaffle_test.rb:4 +Instead of requiring users to manually include `ActsAsApiResource` in their +`ApplicationRecord`, you can use a Railtie to do it automatically: -E +```ruby +# api_boost/lib/api_boost/railtie.rb -Error: -ActsAsYaffleTest#test_a_wickwalls_yaffle_text_field_should_be_last_tweet: -NoMethodError: undefined method `yaffle_text_field' for # +module ApiBoost + class Railtie < Rails::Railtie + config.api_boost = ActiveSupport::OrderedOptions.new + config.api_boost.default_rate_limit = 60.requests_per_hour + initializer "api_boost.configure" do |app| + ApiBoost.configuration = app.config.api_boost + end -bin/test /path/to/yaffle/test/acts_as_yaffle_test.rb:8 + initializer "api_boost.active_record" do + ActiveSupport.on_load(:active_record) do + include ApiBoost::ActsAsApiResource + end + end + end +end +``` -. +The `ActiveSupport.on_load` hook ensures your module is included at the right +time during Rails initialization, after ActiveRecord is fully loaded. -Finished in 0.008263s, 484.0999 runs/s, 242.0500 assertions/s. -4 runs, 2 assertions, 0 failures, 2 errors, 0 skips -``` +### Rake Tasks -Getting closer... Now we will implement the code of the `acts_as_yaffle` method to make the tests pass. +To provide Rake tasks to applications using your plugin: ```ruby -# yaffle/lib/yaffle/acts_as_yaffle.rb +# api_boost/lib/api_boost/railtie.rb -module Yaffle - module ActsAsYaffle - extend ActiveSupport::Concern +module ApiBoost + class Railtie < Rails::Railtie + # ... existing configuration ... - class_methods do - def acts_as_yaffle(options = {}) - cattr_accessor :yaffle_text_field, default: (options[:yaffle_text_field] || :last_squawk).to_s - end + rake_tasks do + load "tasks/api_boost_tasks.rake" end end end ``` +Create the Rake task file: + ```ruby -# test/dummy/app/models/application_record.rb +# api_boost/lib/tasks/api_boost_tasks.rake -class ApplicationRecord < ActiveRecord::Base - include Yaffle::ActsAsYaffle +namespace :api_boost do + desc "Show API usage statistics" + task stats: :environment do + puts "API Boost Statistics:" + puts "Models using acts_as_api_resource: #{api_resource_models.count}" + end - self.abstract_class = true + def api_resource_models + ApplicationRecord.descendants.select do |model| + model.include?(ApiBoost::ActsAsApiResource) + end + end end ``` -When you run `bin/test`, you should see the tests all pass: +Applications using your plugin will now have access to `rails api_boost:stats`. -```bash -$ bin/test -... -4 runs, 4 assertions, 0 failures, 0 errors, 0 skips -``` +### Testing the Railtie -### Add an Instance Method - -This plugin will add a method named 'squawk' to any Active Record object that calls `acts_as_yaffle`. The 'squawk' -method will simply set the value of one of the fields in the database. - -To start out, write a failing test that shows the behavior you'd like: +You can test that your Railtie works correctly in the dummy application: ```ruby -# yaffle/test/acts_as_yaffle_test.rb -require "test_helper" +# api_boost/test/railtie_test.rb -class ActsAsYaffleTest < ActiveSupport::TestCase - def test_a_hickwalls_yaffle_text_field_should_be_last_squawk - assert_equal "last_squawk", Hickwall.yaffle_text_field - end +require "test_helper" - def test_a_wickwalls_yaffle_text_field_should_be_last_tweet - assert_equal "last_tweet", Wickwall.yaffle_text_field +class RailtieTest < ActiveSupport::TestCase + def test_configuration_is_available + assert_not_nil ApiBoost.configuration + assert_equal 60.requests_per_hour, ApiBoost.configuration.default_rate_limit end - def test_hickwalls_squawk_should_populate_last_squawk - hickwall = Hickwall.new - hickwall.squawk("Hello World") - assert_equal "squawk! Hello World", hickwall.last_squawk + def test_acts_as_api_resource_is_automatically_included + assert Class.new(ApplicationRecord).include?(ApiBoost::ActsAsApiResource) end - def test_wickwalls_squawk_should_populate_last_tweet - wickwall = Wickwall.new - wickwall.squawk("Hello World") - assert_equal "squawk! Hello World", wickwall.last_tweet + def test_rake_tasks_are_loaded + Rails.application.load_tasks + assert Rake::Task.task_defined?("api_boost:stats") end end ``` -Run the test to make sure the last two tests fail with an error that contains "NoMethodError: undefined method \`squawk'", -then update `acts_as_yaffle.rb` to look like this: +Railties provide a clean way to integrate your plugin with Rails' initialization +process. For more details about the complete Rails initialization lifecycle, see +the [Rails Initialization Process Guide](initialization.html). + +Testing Your Plugin +------------------- + +It's good practice to add tests. The Rails +plugin generator created a test framework for you. Let's add tests for the +functionality we just built. + +### Testing Core Extensions + +Create a test file for your core extensions: ```ruby -# yaffle/lib/yaffle/acts_as_yaffle.rb +# api_boost/test/core_ext_test.rb -module Yaffle - module ActsAsYaffle - extend ActiveSupport::Concern +require "test_helper" - included do - def squawk(string) - write_attribute(self.class.yaffle_text_field, string.to_squawk) - end - end +class CoreExtTest < ActiveSupport::TestCase + def test_to_throttled_response_adds_rate_limit_header + response_data = "Hello API" + expected = { data: "Hello API", rate_limit: 60.requests_per_hour } + assert_equal expected, response_data.to_throttled_response + end - class_methods do - def acts_as_yaffle(options = {}) - cattr_accessor :yaffle_text_field, default: (options[:yaffle_text_field] || :last_squawk).to_s - end - end + def test_to_throttled_response_with_custom_limit + response_data = "User data" + expected = { data: "User data", rate_limit: "100 requests per hour" } + assert_equal expected, response_data.to_throttled_response("100 requests per hour") end end ``` +### Testing Acts As Methods + +Create a test file for your ActsAs functionality: + ```ruby -# test/dummy/app/models/application_record.rb +# api_boost/test/acts_as_api_resource_test.rb -class ApplicationRecord < ActiveRecord::Base - include Yaffle::ActsAsYaffle +require "test_helper" - self.abstract_class = true +class ActsAsApiResourceTest < ActiveSupport::TestCase + def test_a_users_api_timestamp_field_should_be_last_requested_at + assert_equal "last_requested_at", User.api_timestamp_field + end + + def test_a_products_api_timestamp_field_should_be_last_api_call + assert_equal "last_api_call", Product.api_timestamp_field + end + + def test_users_track_api_request_should_populate_last_requested_at + user = User.new + freeze_time = Time.current + Time.stub(:current, freeze_time) do + user.track_api_request + assert_equal freeze_time.to_s, user.last_requested_at.to_s + end + end + + def test_products_track_api_request_should_populate_last_api_call + product = Product.new + freeze_time = Time.current + Time.stub(:current, freeze_time) do + product.track_api_request + assert_equal freeze_time.to_s, product.last_api_call.to_s + end + end end ``` -Run `bin/test` one final time, and you should see: +Run your tests to make sure everything is working: ```bash $ bin/test @@ -468,71 +722,68 @@ $ bin/test 6 runs, 6 assertions, 0 failures, 0 errors, 0 skips ``` -NOTE: The use of `write_attribute` to write to the field in model is just one example of how a plugin can interact with the model, and will not always be the right method to use. For example, you could also use: - -```ruby -send("#{self.class.yaffle_text_field}=", string.to_squawk) -``` - Generators ---------- -Generators can be included in your gem simply by creating them in a `lib/generators` directory of your plugin. More information about -the creation of generators can be found in the [Generators Guide](generators.html). +Generators can be included in your gem simply by creating them in a +`lib/generators` directory of your plugin. More information about the creation +of generators can be found in the [Generators Guide](generators.html). Publishing Your Gem ------------------- -Gem plugins currently in development can easily be shared from any Git repository. To share the Yaffle gem with others, simply -commit the code to a Git repository (like GitHub) and add a line to the `Gemfile` of the application in question: +Gem plugins currently in development can easily be shared from any Git +repository. To share the ApiBoost gem with others, simply commit the code to a +Git repository (like GitHub) and add a line to the `Gemfile` of the application +in question: ```ruby -gem "yaffle", git: "https://github.com/rails/yaffle.git" +gem "api_boost", git: "https://github.com/YOUR_GITHUB_HANDLE/api_boost.git" ``` -After running `bundle install`, your gem functionality will be available to the application. +After running `bundle install`, your gem functionality will be available to the +application. -When the gem is ready to be shared as a formal release, it can be published to [RubyGems](https://rubygems.org). +When the gem is ready to be shared as a formal release, it can be published to +[RubyGems](https://rubygems.org). -Alternatively, you can benefit from Bundler's Rake tasks. You can see a full list with the following: +Alternatively, you can benefit from Bundler's Rake tasks. You can see a full +list with the following: ```bash $ bundle exec rake -T $ bundle exec rake build -# Build yaffle-0.1.0.gem into the pkg directory +# Build api_boost-0.1.0.gem into the pkg directory $ bundle exec rake install -# Build and install yaffle-0.1.0.gem into system gems +# Build and install api_boost-0.1.0.gem into system gems $ bundle exec rake release -# Create tag v0.1.0 and build and push yaffle-0.1.0.gem to Rubygems +# Create tag v0.1.0 and build and push api_boost-0.1.0.gem to Rubygems ``` -For more information about publishing gems to RubyGems, see: [Publishing your gem](https://guides.rubygems.org/publishing). +For more information about publishing gems to RubyGems, see: [Publishing your +gem](https://guides.rubygems.org/publishing). RDoc Documentation ------------------ -Once your plugin is stable, and you are ready to deploy, do everyone else a favor and document it! Luckily, writing documentation for your plugin is easy. - -The first step is to update the README file with detailed information about how to use your plugin. A few key things to include are: +Once your plugin is stable, you can write documentation for it. The first step +is to update the `README.md` file with detailed information about how to use +your plugin. A few key things to include are: * Your name * How to install * How to add the functionality to the app (several examples of common use cases) * Warnings, gotchas or tips that might help users and save them time -Once your README is solid, go through and add RDoc comments to all the methods that developers will use. It's also customary to add `# :nodoc:` comments to those parts of the code that are not included in the public API. +Once your `README.md` is solid, go through and add RDoc comments to all the +methods that developers will use. It's also customary to add `# :nodoc:` +comments to those parts of the code that are not included in the public API. Once your comments are good to go, navigate to your plugin directory and run: ```bash $ bundle exec rake rdoc ``` - -### References - -* [Developing a RubyGem using Bundler](https://bundler.io/guides/creating_gem.html) -* [Using .gemspecs as Intended](https://yehudakatz.com/2010/04/02/using-gemspecs-as-intended/) -* [Gemspec Reference](https://guides.rubygems.org/specification-reference/) From 119a939f290e4c28b8a591070b6d621973081a0a Mon Sep 17 00:00:00 2001 From: Adrianna Chang Date: Tue, 19 Aug 2025 12:33:07 -0400 Subject: [PATCH 0471/1075] Remove default JSON and MessagePack encoders from EventReporter. The use case for the default encoders is not quite clear enough, so we're removing them until the pattern to extract is more obvious. --- activesupport/CHANGELOG.md | 14 +---- .../lib/active_support/event_reporter.rb | 32 +----------- .../event_reporter/json_encoder.rb | 51 ------------------- .../event_reporter/message_pack_encoder.rb | 27 ---------- activesupport/test/event_reporter_test.rb | 46 ----------------- 5 files changed, 2 insertions(+), 168 deletions(-) delete mode 100644 activesupport/lib/active_support/event_reporter/json_encoder.rb delete mode 100644 activesupport/lib/active_support/event_reporter/message_pack_encoder.rb diff --git a/activesupport/CHANGELOG.md b/activesupport/CHANGELOG.md index 2870a3ef3c3d1..8ec668ab304e0 100644 --- a/activesupport/CHANGELOG.md +++ b/activesupport/CHANGELOG.md @@ -50,19 +50,7 @@ ``` Events are emitted to subscribers. Applications register subscribers to - control how events are serialized and emitted. Rails provides several default - encoders that can be used to serialize events to common formats: - - ```ruby - class MySubscriber - def emit(event) - encoded_event = ActiveSupport::EventReporter::JSONEncoder.encode(event) - StructuredLogExporter.export(encoded_event) - end - end - - Rails.event.subscribe(MySubscriber.new) - ``` + control how events are serialized and emitted. *Adrianna Chang* diff --git a/activesupport/lib/active_support/event_reporter.rb b/activesupport/lib/active_support/event_reporter.rb index 9dfbf01d0b5cb..952b64191e560 100644 --- a/activesupport/lib/active_support/event_reporter.rb +++ b/activesupport/lib/active_support/event_reporter.rb @@ -124,32 +124,7 @@ def clear # An event is any Ruby object representing a schematized event. While payload hashes allow arbitrary, # implicitly-structured data, event objects are intended to enforce a particular schema. # - # ==== Default Encoders - # - # Rails provides default encoders for common serialization formats. Event objects and tags MUST - # implement +to_h+ to be serialized. - # - # class JSONLogSubscriber - # def emit(event) - # # event = { name: "UserCreatedEvent", payload: { UserCreatedEvent: # } } - # json_data = ActiveSupport::EventReporter::JSONEncoder.encode(event) - # # => { - # # "name": "UserCreatedEvent", - # # "payload": { - # # "id": 123, - # # "name": "John Doe" - # # } - # # } - # Rails.logger.info(json_data) - # end - # end - # - # class MessagePackSubscriber - # def emit(event) - # msgpack_data = ActiveSupport::EventReporter::MessagePackEncoder.encode(event) - # BatchExporter.export(msgpack_data) - # end - # end + # Subscribers are responsible for serializing events to their desired format. # # ==== Debug Events # @@ -228,11 +203,6 @@ def clear # # payload: { id: 123 }, # # } class EventReporter - extend ActiveSupport::Autoload - - autoload :JSONEncoder - autoload :MessagePackEncoder - attr_writer :raise_on_error # :nodoc: attr_reader :subscribers diff --git a/activesupport/lib/active_support/event_reporter/json_encoder.rb b/activesupport/lib/active_support/event_reporter/json_encoder.rb deleted file mode 100644 index 0d750619c5309..0000000000000 --- a/activesupport/lib/active_support/event_reporter/json_encoder.rb +++ /dev/null @@ -1,51 +0,0 @@ -# frozen_string_literal: true - -require "json" - -module ActiveSupport - class EventReporter - # JSON encoder for serializing events to JSON format. - # - # event = { name: "user_created", payload: { id: 123 }, tags: { api: true } } - # ActiveSupport::EventReporter::JSONEncoder.encode(event) - # # => { - # # "name": "user_created", - # # "payload": { - # # "id": 123 - # # }, - # # "tags": { - # # "api": true - # # }, - # # "context": {} - # # } - # - # Schematized events and tags MUST respond to #to_h to be serialized. - # - # event = { name: "UserCreatedEvent", payload: #, tags: { "GraphqlTag": # } } - # ActiveSupport::EventReporter::JSONEncoder.encode(event) - # # => { - # # "name": "UserCreatedEvent", - # # "payload": { - # # "id": 123 - # # }, - # # "tags": { - # # "GraphqlTag": { - # # "operation_name": "user_created", - # # "operation_type": "mutation" - # # } - # # }, - # # "context": {} - # # } - module JSONEncoder - class << self - def encode(event) - event[:payload] = event[:payload].to_h - event[:tags] = event[:tags].transform_values do |value| - value.respond_to?(:to_h) ? value.to_h : value - end - ::JSON.generate(event) - end - end - end - end -end diff --git a/activesupport/lib/active_support/event_reporter/message_pack_encoder.rb b/activesupport/lib/active_support/event_reporter/message_pack_encoder.rb deleted file mode 100644 index 5da108e7b6ea8..0000000000000 --- a/activesupport/lib/active_support/event_reporter/message_pack_encoder.rb +++ /dev/null @@ -1,27 +0,0 @@ -# frozen_string_literal: true - -begin - gem "msgpack", ">= 1.7.0" - require "msgpack" -rescue LoadError => error - warn "ActiveSupport::EventReporter::MessagePackEncoder requires the msgpack gem, version 1.7.0 or later. " \ - "Please add it to your Gemfile: `gem \"msgpack\", \">= 1.7.0\"`" - raise error -end - -module ActiveSupport - class EventReporter - # EventReporter encoder for serializing events to MessagePack format. - module MessagePackEncoder - class << self - def encode(event) - event[:payload] = event[:payload].to_h - event[:tags] = event[:tags].transform_values do |value| - value.respond_to?(:to_h) ? value.to_h : value - end - ::MessagePack.pack(event) - end - end - end - end -end diff --git a/activesupport/test/event_reporter_test.rb b/activesupport/test/event_reporter_test.rb index 7515701e3e458..1f774ce3f41fd 100644 --- a/activesupport/test/event_reporter_test.rb +++ b/activesupport/test/event_reporter_test.rb @@ -560,51 +560,5 @@ def to_h source_location: { filepath: "/path/to/file.rb", lineno: 42, label: "test_method" } } end - - test "JSON encoder encodes event to JSON" do - json_string = EventReporter::JSONEncoder.encode(@event) - parsed = ::JSON.parse(json_string) - - assert_equal "test_event", parsed["name"] - assert_equal({ "id" => 123, "message" => "hello" }, parsed["payload"]) - assert_equal({ "section" => "admin" }, parsed["tags"]) - assert_equal({ "user_id" => 456 }, parsed["context"]) - assert_equal 1738964843208679035, parsed["timestamp"] - assert_equal({ "filepath" => "/path/to/file.rb", "lineno" => 42, "label" => "test_method" }, parsed["source_location"]) - end - - test "JSON encoder serializes event objects and object tags as hashes" do - @event[:payload] = TestEvent.new("value") - @event[:tags] = { "HttpRequestTag": HttpRequestTag.new("GET", 200) } - json_string = EventReporter::JSONEncoder.encode(@event) - parsed = ::JSON.parse(json_string) - - assert_equal "value", parsed["payload"]["data"] - assert_equal "GET", parsed["tags"]["HttpRequestTag"]["http_method"] - assert_equal 200, parsed["tags"]["HttpRequestTag"]["http_status"] - end - - test "MessagePack encoder encodes event to MessagePack" do - msgpack_data = EventReporter::MessagePackEncoder.encode(@event) - parsed = ::MessagePack.unpack(msgpack_data) - - assert_equal "test_event", parsed["name"] - assert_equal({ "id" => 123, "message" => "hello" }, parsed["payload"]) - assert_equal({ "section" => "admin" }, parsed["tags"]) - assert_equal({ "user_id" => 456 }, parsed["context"]) - assert_equal 1738964843208679035, parsed["timestamp"] - assert_equal({ "filepath" => "/path/to/file.rb", "lineno" => 42, "label" => "test_method" }, parsed["source_location"]) - end - - test "MessagePack encoder serializes event objects and object tags as hashes" do - @event[:payload] = TestEvent.new("value") - @event[:tags] = { "HttpRequestTag": HttpRequestTag.new("GET", 200) } - msgpack_data = EventReporter::MessagePackEncoder.encode(@event) - parsed = ::MessagePack.unpack(msgpack_data) - - assert_equal "value", parsed["payload"]["data"] - assert_equal "GET", parsed["tags"]["HttpRequestTag"]["http_method"] - assert_equal 200, parsed["tags"]["HttpRequestTag"]["http_status"] - end end end From 0a45b82cb09edd9948c1e796c7b3fd65495d8e60 Mon Sep 17 00:00:00 2001 From: Adrianna Chang Date: Tue, 19 Aug 2025 15:35:07 -0400 Subject: [PATCH 0472/1075] Fix some of the API docs for the Event Reporter. [ci skip] --- .../lib/active_support/event_reporter.rb | 31 ++++++++++++------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/activesupport/lib/active_support/event_reporter.rb b/activesupport/lib/active_support/event_reporter.rb index 9dfbf01d0b5cb..c429370065b11 100644 --- a/activesupport/lib/active_support/event_reporter.rb +++ b/activesupport/lib/active_support/event_reporter.rb @@ -98,19 +98,19 @@ def clear # If an event object is passed to the +notify+ API, it will be passed through to subscribers as-is, and the name of the # object's class will be used as the event name. # - # class UserCreatedEvent - # def initialize(id:, name:) - # @id = id - # @name = name - # end + # class UserCreatedEvent + # def initialize(id:, name:) + # @id = id + # @name = name + # end # - # def to_h - # { - # id: @id, - # name: @name - # } + # def to_h + # { + # id: @id, + # name: @name + # } + # end # end - # end # # Rails.event.notify(UserCreatedEvent.new(id: 123, name: "John Doe")) # # Emits event: @@ -172,6 +172,7 @@ def clear # # name: "user_created", # # payload: { id: 123 }, # # tags: { graphql: true }, + # # context: {}, # # timestamp: 1738964843208679035, # # source_location: { filepath: "path/to/file.rb", lineno: 123, label: "UserService#create" } # # } @@ -190,6 +191,7 @@ def clear # # { # # name: "user_created", # # payload: { id: 123 }, + # # tags: {}, # # context: { request_id: "abcd123", user_agent: TestAgent" }, # # timestamp: 1738964843208679035, # # source_location: { filepath: "path/to/file.rb", lineno: 123, label: "UserService#create" } @@ -257,6 +259,7 @@ def initialize(*subscribers, raise_on_error: false, tags: nil) # name: String (The name of the event) # payload: Hash, Object (The payload of the event, or the event object itself) # tags: Hash (The tags of the event) + # context: Hash (The context of the event) # timestamp: Float (The timestamp of the event, in nanoseconds) # source_location: Hash (The source location of the event, containing the filepath, lineno, and label) # @@ -281,6 +284,7 @@ def subscribe(subscriber, &filter) # # name: "user.created", # # payload: { id: 123 }, # # tags: {}, + # # context: {}, # # timestamp: 1738964843208679035, # # source_location: { filepath: "path/to/file.rb", lineno: 123, label: "UserService#create" } # # } @@ -293,6 +297,7 @@ def subscribe(subscriber, &filter) # # name: "UserCreatedEvent", # # payload: #, # # tags: {}, + # # context: {}, # # timestamp: 1738964843208679035, # # source_location: { filepath: "path/to/file.rb", lineno: 123, label: "UserService#create" } # # } @@ -395,6 +400,7 @@ def debug(name_or_object, payload = nil, caller_depth: 1, **kwargs) # # name: "user.created", # # payload: { id: 123 }, # # tags: { graphql: true }, + # # context: {}, # # timestamp: 1738964843208679035, # # source_location: { filepath: "path/to/file.rb", lineno: 123, label: "UserService#create" } # # } @@ -413,6 +419,7 @@ def debug(name_or_object, payload = nil, caller_depth: 1, **kwargs) # # name: "user.created", # # payload: { id: 123 }, # # tags: { section: "admin", graphql: true }, + # # context: {}, # # timestamp: 1738964843208679035, # # source_location: { filepath: "path/to/file.rb", lineno: 123, label: "UserService#create" } # # } @@ -429,6 +436,7 @@ def debug(name_or_object, payload = nil, caller_depth: 1, **kwargs) # # name: "user.created", # # payload: { id: 123 }, # # tags: { "GraphqlTag": # }, + # # context: {}, # # timestamp: 1738964843208679035, # # source_location: { filepath: "path/to/file.rb", lineno: 123, label: "UserService#create" } # # } @@ -453,6 +461,7 @@ def tagged(*args, **kwargs, &block) # # tags: { graphql: true }, # # context: { user_agent: "TestAgent", job_id: "abc123" }, # # timestamp: 1738964843208679035 + # # source_location: { filepath: "path/to/file.rb", lineno: 123, label: "UserService#create" } # # } def set_context(context) context_store.set_context(context) From df9e361c5f6b25f1adb2ced1e654c6692734810d Mon Sep 17 00:00:00 2001 From: Jill Klang Date: Thu, 26 Jun 2025 09:45:06 -0400 Subject: [PATCH 0473/1075] filter sensitive attributes This update addresses inconsistencies between filter_parameters and filter_attributes in log filtering. By ensuring that when parameters are defined for filtering, they are also consistently applied to attributes, we eliminate unexpected behaviors and improve overall logging accuracy. --- activerecord/CHANGELOG.md | 5 ++ activerecord/lib/active_record.rb | 1 + activerecord/lib/active_record/core.rb | 2 + .../active_record/filter_attribute_handler.rb | 73 +++++++++++++++++++ activerecord/lib/active_record/railtie.rb | 4 + .../application/active_record_railtie_test.rb | 54 ++++++++++++++ 6 files changed, 139 insertions(+) create mode 100644 activerecord/lib/active_record/filter_attribute_handler.rb diff --git a/activerecord/CHANGELOG.md b/activerecord/CHANGELOG.md index 485f8627cc667..73b868cfc6064 100644 --- a/activerecord/CHANGELOG.md +++ b/activerecord/CHANGELOG.md @@ -1,3 +1,8 @@ +* Attributes filtered by `filter_attributes` will now also be filtered by `filter_parameters` + so sensitive information is not leaked. + + *Jill Klang* + * Add `connection.current_transaction.isolation` API to check current transaction's isolation level. Returns the isolation level if it was explicitly set via the `isolation:` parameter diff --git a/activerecord/lib/active_record.rb b/activerecord/lib/active_record.rb index 6e3f7c28a9496..137506cc61cc1 100644 --- a/activerecord/lib/active_record.rb +++ b/activerecord/lib/active_record.rb @@ -53,6 +53,7 @@ module ActiveRecord autoload :Enum autoload :Explain autoload :FixtureSet, "active_record/fixtures" + autoload :FilterAttributeHandler autoload :Inheritance autoload :Integration autoload :InternalMetadata diff --git a/activerecord/lib/active_record/core.rb b/activerecord/lib/active_record/core.rb index d34c7c3a74a23..67db2a69200ca 100644 --- a/activerecord/lib/active_record/core.rb +++ b/activerecord/lib/active_record/core.rb @@ -357,6 +357,8 @@ def filter_attributes def filter_attributes=(filter_attributes) @inspection_filter = nil @filter_attributes = filter_attributes + + FilterAttributeHandler.sensitive_attribute_was_declared(self, filter_attributes) end def inspection_filter # :nodoc: diff --git a/activerecord/lib/active_record/filter_attribute_handler.rb b/activerecord/lib/active_record/filter_attribute_handler.rb new file mode 100644 index 0000000000000..d700e103bbf27 --- /dev/null +++ b/activerecord/lib/active_record/filter_attribute_handler.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +module ActiveRecord + class FilterAttributeHandler # :nodoc: + class << self + def on_sensitive_attribute_declared(&block) + @sensitive_attribute_declaration_listeners ||= Concurrent::Array.new + @sensitive_attribute_declaration_listeners << block + end + + def sensitive_attribute_was_declared(klass, list) + @sensitive_attribute_declaration_listeners&.each do |block| + block.call(klass, list) + end + end + end + + def initialize(app) + @app = app + @attributes_by_class = Concurrent::Map.new + @collecting = true + end + + def enable + install_collecting_hook + + apply_collected_attributes + @collecting = false + end + + private + attr_reader :app + + def install_collecting_hook + self.class.on_sensitive_attribute_declared do |klass, list| + attribute_was_declared(klass, list) + end + end + + def attribute_was_declared(klass, list) + if collecting? + collect_for_later(klass, list) + else + apply_filter(klass, list) + end + end + + def apply_collected_attributes + @attributes_by_class.each do |klass, list| + apply_filter(klass, list) + end + end + + def collecting? + @collecting + end + + def collect_for_later(klass, list) + @attributes_by_class[klass] ||= Concurrent::Array.new + @attributes_by_class[klass] += list + end + + def apply_filter(klass, list) + list.each do |attribute| + next if klass.abstract_class? + + klass_name = klass.name ? klass.model_name.element : nil + filter = [klass_name, attribute.to_s].compact.join(".") + app.config.filter_parameters << filter unless app.config.filter_parameters.include?(filter) + end + end + end +end diff --git a/activerecord/lib/active_record/railtie.rb b/activerecord/lib/active_record/railtie.rb index 023a3eafc0483..bfc18d8247676 100644 --- a/activerecord/lib/active_record/railtie.rb +++ b/activerecord/lib/active_record/railtie.rb @@ -331,6 +331,10 @@ class Railtie < Rails::Railtie # :nodoc: end end + initializer "active_record.filter_attributes_as_log_parameters" do |app| + ActiveRecord::FilterAttributeHandler.new(app).enable + end + initializer "active_record.configure_message_verifiers" do |app| ActiveRecord.message_verifiers = app.message_verifiers diff --git a/railties/test/application/active_record_railtie_test.rb b/railties/test/application/active_record_railtie_test.rb index 05a516644f2d5..a4704fc5f7694 100644 --- a/railties/test/application/active_record_railtie_test.rb +++ b/railties/test/application/active_record_railtie_test.rb @@ -31,5 +31,59 @@ def perform(*) end assert_includes exception.message, "ActiveJob::Continuation::CheckpointError: Cannot checkpoint job with open transactions" end + + test "filter_attributes include filter_parameters" do + app_file "config/initializers/parameter_filter.rb", <<~RUBY + Rails.application.config.filter_parameters += [ :special_param ] + RUBY + app "development" + + assert_includes ActiveRecord::Base.filter_attributes, :special_param + end + + test "filter_paramenters include filter_attributes for an AR::Base subclass" do + app "development" + + assert_not_includes ActiveRecord::Base.filter_attributes, "messsage.special_attr" + + class Message < ActiveRecord::Base + self.table_name = "messages" + self.filter_attributes += [:special_attr] + end + + assert_includes Rails.application.config.filter_parameters, "message.special_attr" + end + + test "filter_paramenters include filter_attributes for AR::Base subclasses" do + app "development" + + assert_not_includes ActiveRecord::Base.filter_attributes, "special_attr" + + class Message < ActiveRecord::Base + self.table_name = "messages" + end + + Message.filter_attributes += [ :special_attr ] + + assert_includes Rails.application.config.filter_parameters, "message.special_attr" + end + + test "filter_parameters are inherited from AR parent classes" do + app "development" + + class ApplicationRecord < ActiveRecord::Base + self.abstract_class = true + self.filter_attributes += [ "expires_at" ] + end + + class CreditCard < ApplicationRecord + self.table_name = "credit_cards" + self.filter_attributes += [ "digits" ] + end + + assert_includes Rails.application.config.filter_parameters, "credit_card.expires_at" + assert_includes Rails.application.config.filter_parameters, "credit_card.digits" + assert_not_includes Rails.application.config.filter_parameters, "application_record.expires_at" + end end end From 4de4e6ec94826b2bb220b9562063dcd5783e0358 Mon Sep 17 00:00:00 2001 From: Adrianna Chang Date: Wed, 20 Aug 2025 15:19:58 -0400 Subject: [PATCH 0474/1075] Better docs on Event Reporter subscribers --- activesupport/CHANGELOG.md | 14 ++- .../lib/active_support/event_reporter.rb | 88 +++++++++++++++---- 2 files changed, 85 insertions(+), 17 deletions(-) diff --git a/activesupport/CHANGELOG.md b/activesupport/CHANGELOG.md index 8ec668ab304e0..e02e5b049d8d2 100644 --- a/activesupport/CHANGELOG.md +++ b/activesupport/CHANGELOG.md @@ -50,7 +50,19 @@ ``` Events are emitted to subscribers. Applications register subscribers to - control how events are serialized and emitted. + control how events are serialized and emitted. Subscribers must implement + an `#emit` method, which receives the event hash: + + ```ruby + class LogSubscriber + def emit(event) + payload = event[:payload].map { |key, value| "#{key}=#{value}" }.join(" ") + source_location = event[:source_location] + log = "[#{event[:name]}] #{payload} at #{source_location[:filepath]}:#{source_location[:lineno]}" + Rails.logger.info(log) + end + end + ``` *Adrianna Chang* diff --git a/activesupport/lib/active_support/event_reporter.rb b/activesupport/lib/active_support/event_reporter.rb index a42b27191b01d..c0ad55a09fc9f 100644 --- a/activesupport/lib/active_support/event_reporter.rb +++ b/activesupport/lib/active_support/event_reporter.rb @@ -81,19 +81,9 @@ def clear # # source_location: { filepath: "path/to/file.rb", lineno: 123, label: "UserService#create" } # # } # - # ==== Filtered Subscriptions - # - # Subscribers can be configured with an optional filter proc to only receive a subset of events: - # - # # Only receive events with names starting with "user." - # Rails.event.subscribe(user_subscriber) { |event| event[:name].start_with?("user.") } - # - # # Only receive events with specific payload types - # Rails.event.subscribe(audit_subscriber) { |event| event[:payload].is_a?(AuditEvent) } - # # The +notify+ API can receive either an event name and a payload hash, or an event object. Names are coerced to strings. # - # ==== Event Objects + # === Event Objects # # If an event object is passed to the +notify+ API, it will be passed through to subscribers as-is, and the name of the # object's class will be used as the event name. @@ -104,7 +94,7 @@ def clear # @name = name # end # - # def to_h + # def serialize # { # id: @id, # name: @name @@ -124,16 +114,82 @@ def clear # An event is any Ruby object representing a schematized event. While payload hashes allow arbitrary, # implicitly-structured data, event objects are intended to enforce a particular schema. # - # Subscribers are responsible for serializing events to their desired format. + # Subscribers are responsible for serializing event objects. + # + # === Subscribers + # + # Subscribers must implement the +emit+ method, which will be called with the event hash. + # + # The event hash has the following keys: + # + # name: String (The name of the event) + # payload: Hash, Object (The payload of the event, or the event object itself) + # tags: Hash (The tags of the event) + # context: Hash (The context of the event) + # timestamp: Float (The timestamp of the event, in nanoseconds) + # source_location: Hash (The source location of the event, containing the filepath, lineno, and label) + # + # Subscribers are responsible for encoding events to their desired format before emitting them to their + # target destination, such as a streaming platform, a log device, or an alerting service. + # + # class JSONEventSubscriber + # def emit(event) + # json_data = JSON.generate(event) + # LogExporter.export(json_data) + # end + # end + # + # class LogSubscriber + # def emit(event) + # payload = event[:payload].map { |key, value| "#{key}=#{value}" }.join(" ") + # source_location = event[:source_location] + # log = "[#{event[:name]}] #{payload} at #{source_location[:filepath]}:#{source_location[:lineno]}" + # Rails.logger.info(log) + # end + # end + # + # Note that event objects are passed through to subscribers as-is, and may need to be serialized before being encoded: + # + # class UserCreatedEvent + # def initialize(id:, name:) + # @id = id + # @name = name + # end + # + # def serialize + # { + # id: @id, + # name: @name + # } + # end + # end + # + # class LogSubscriber + # def emit(event) + # payload = event[:payload] + # json_data = JSON.generate(payload.serialize) + # LogExporter.export(json_data) + # end + # end + # + # ==== Filtered Subscriptions + # + # Subscribers can be configured with an optional filter proc to only receive a subset of events: + # + # # Only receive events with names starting with "user." + # Rails.event.subscribe(user_subscriber) { |event| event[:name].start_with?("user.") } + # + # # Only receive events with specific payload types + # Rails.event.subscribe(audit_subscriber) { |event| event[:payload].is_a?(AuditEvent) } # - # ==== Debug Events + # === Debug Events # # You can use the +debug+ method to report an event that will only be reported if the # event reporter is in debug mode: # # Rails.event.debug("my_debug_event", { foo: "bar" }) # - # ==== Tags + # === Tags # # To add additional context to an event, separate from the event payload, you can add # tags via the +tagged+ method: @@ -152,7 +208,7 @@ def clear # # source_location: { filepath: "path/to/file.rb", lineno: 123, label: "UserService#create" } # # } # - # ==== Context Store + # === Context Store # # You may want to attach metadata to every event emitted by the reporter. While tags # provide domain-specific context for a series of events, context is scoped to the job / request From 0d25a5114f4a40708c599cbee7298bd8856279f1 Mon Sep 17 00:00:00 2001 From: ojab Date: Thu, 21 Aug 2025 13:08:58 +0000 Subject: [PATCH 0475/1075] Fix typo in the comment `false` wasn't changed to `true` during `#html_{,un}safe` change Fixes db27b67bb9ae8194bdb38559cac194707086de87 --- .../lib/active_support/core_ext/string/output_safety.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/activesupport/lib/active_support/core_ext/string/output_safety.rb b/activesupport/lib/active_support/core_ext/string/output_safety.rb index 22bd945c2373d..67c5c43d58aa9 100644 --- a/activesupport/lib/active_support/core_ext/string/output_safety.rb +++ b/activesupport/lib/active_support/core_ext/string/output_safety.rb @@ -181,7 +181,7 @@ def #{unsafe_method}(*args, &block) # def gsub(*args, &block) end # end def #{unsafe_method}!(*args, &block) # def gsub!(*args, &block) - @html_unsafe = true # @html_unsafe = false + @html_unsafe = true # @html_unsafe = true if block # if block super(*args) { |*params| # super(*args) { |*params| set_block_back_references(block, $~) # set_block_back_references(block, $~) From 54e0b2652b8ed8fc70fb2d6f0bf5f7c30b182e80 Mon Sep 17 00:00:00 2001 From: Florent Beaurain Date: Wed, 20 Aug 2025 15:36:37 +0200 Subject: [PATCH 0476/1075] Fix stale state for polymorphic relationship Co-authored-by: Thomas Crambert --- .../associations/belongs_to_association.rb | 4 ++-- .../associations/belongs_to_associations_test.rb | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/activerecord/lib/active_record/associations/belongs_to_association.rb b/activerecord/lib/active_record/associations/belongs_to_association.rb index 7e595a39aee27..93674a3d3c76d 100644 --- a/activerecord/lib/active_record/associations/belongs_to_association.rb +++ b/activerecord/lib/active_record/associations/belongs_to_association.rb @@ -162,9 +162,9 @@ def invertible_for?(record) end def stale_state - Array(reflection.foreign_key).map do |fk| + Array(reflection.foreign_key).filter_map do |fk| owner._read_attribute(fk) { |n| owner.send(:missing_attribute, n, caller) } - end + end.presence end end end diff --git a/activerecord/test/cases/associations/belongs_to_associations_test.rb b/activerecord/test/cases/associations/belongs_to_associations_test.rb index 0fef372b5042a..36f6e2ca17e7e 100644 --- a/activerecord/test/cases/associations/belongs_to_associations_test.rb +++ b/activerecord/test/cases/associations/belongs_to_associations_test.rb @@ -2,6 +2,7 @@ require "cases/helper" require "support/deprecated_associations_test_helpers" +require "models/attachment" require "models/developer" require "models/project" require "models/company" @@ -1453,6 +1454,20 @@ def test_polymorphic_with_custom_name_touch_old_belongs_to_model assert_equal touch_time, car.reload.wheels_owned_at end + def test_polymorphic_stale_state_handles_nil_foreign_keys_correctly + klass = Class.new(ActiveRecord::Base) do + self.table_name = "records" + + has_one :attachment, as: :record + + def self.polymorphic_name + "Blob" + end + end + + assert_nothing_raised { Attachment.create!(record: klass.build, record_type: "Document") } + end + def test_build_with_conditions client = companies(:second_client) firm = client.build_bob_firm From 87a2e4f8984b9521f7cbe4d2bf250c95bba9a97b Mon Sep 17 00:00:00 2001 From: Eugene Kenny Date: Thu, 21 Aug 2025 23:34:53 +0100 Subject: [PATCH 0477/1075] Remove early return from DatabaseTasks#for_each DatabaseConfigurations#configs_for already handles this. --- activerecord/lib/active_record/tasks/database_tasks.rb | 2 -- 1 file changed, 2 deletions(-) diff --git a/activerecord/lib/active_record/tasks/database_tasks.rb b/activerecord/lib/active_record/tasks/database_tasks.rb index ab0202a2fe294..03b0f786739e9 100644 --- a/activerecord/lib/active_record/tasks/database_tasks.rb +++ b/activerecord/lib/active_record/tasks/database_tasks.rb @@ -147,8 +147,6 @@ def for_each(databases) # :nodoc: return if database_configs.count == 1 database_configs.each do |db_config| - next unless db_config.database_tasks? - yield db_config.name end end From 60358364a11ec644ab9c65f788e2c73c0a5bbac2 Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Fri, 22 Aug 2025 10:02:02 +0200 Subject: [PATCH 0478/1075] Refactor BelongsToAssociation#stale_state Avoid always going through composite primary key path. --- .../associations/belongs_to_association.rb | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/activerecord/lib/active_record/associations/belongs_to_association.rb b/activerecord/lib/active_record/associations/belongs_to_association.rb index 93674a3d3c76d..262bdabae579b 100644 --- a/activerecord/lib/active_record/associations/belongs_to_association.rb +++ b/activerecord/lib/active_record/associations/belongs_to_association.rb @@ -162,9 +162,15 @@ def invertible_for?(record) end def stale_state - Array(reflection.foreign_key).filter_map do |fk| - owner._read_attribute(fk) { |n| owner.send(:missing_attribute, n, caller) } - end.presence + foreign_key = reflection.foreign_key + if foreign_key.is_a?(Array) + attributes = foreign_key.map do |fk| + owner._read_attribute(fk) { |n| owner.send(:missing_attribute, n, caller) } + end + attributes if attributes.any? + else + owner._read_attribute(foreign_key) { |n| owner.send(:missing_attribute, n, caller) } + end end end end From a08a8f2085601317de9aa344ec0a263f22f88518 Mon Sep 17 00:00:00 2001 From: Dmitrii Stepanenko Date: Fri, 22 Aug 2025 14:13:42 +0300 Subject: [PATCH 0479/1075] Remove extra empty line after PostgreSQL `\unrestrict` line from structure.sql --- .../lib/active_record/tasks/postgresql_database_tasks.rb | 8 +++++++- .../cases/adapters/postgresql/postgresql_rake_test.rb | 5 ++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/activerecord/lib/active_record/tasks/postgresql_database_tasks.rb b/activerecord/lib/active_record/tasks/postgresql_database_tasks.rb index a40c6d3e6a23c..6a885bb6ca8b1 100644 --- a/activerecord/lib/active_record/tasks/postgresql_database_tasks.rb +++ b/activerecord/lib/active_record/tasks/postgresql_database_tasks.rb @@ -99,7 +99,13 @@ def remove_sql_header_comments(filename) tempfile = Tempfile.open("uncommented_structure.sql") begin File.foreach(filename) do |line| - next if line.start_with?("\\restrict ", "\\unrestrict ") + next if line.start_with?("\\restrict ") + + if line.start_with?("\\unrestrict ") + removing_comments = true + next + end + unless removing_comments && (line.start_with?(SQL_COMMENT_BEGIN) || line.blank?) tempfile << line removing_comments = false diff --git a/activerecord/test/cases/adapters/postgresql/postgresql_rake_test.rb b/activerecord/test/cases/adapters/postgresql/postgresql_rake_test.rb index 4a7be469d7511..24dc8aee1922b 100644 --- a/activerecord/test/cases/adapters/postgresql/postgresql_rake_test.rb +++ b/activerecord/test/cases/adapters/postgresql/postgresql_rake_test.rb @@ -373,14 +373,17 @@ def test_structure_dump_header_comments_with_restrict_commands_removed statement -- lower comment \\unrestrict pbgv1pF8SxQK6cuT7hwDi21uDYr8wpxKJ3wlLa9Zk5EIO1xBiu84SJQU8fL22PT + + other_statement SQL expected_dump_sql = <<~SQL statement -- lower comment + other_statement SQL File.write(@filename, raw_dump_sql) ActiveRecord::Tasks::DatabaseTasks.structure_dump(@configuration, @filename) - assert_equal expected_dump_sql, File.readlines(@filename).first(2).join + assert_equal expected_dump_sql, File.readlines(@filename).first(3).join end end From 784fff4543992f821d78cd5ba9f1dba7f0fbdb97 Mon Sep 17 00:00:00 2001 From: Earlopain <14981592+Earlopain@users.noreply.github.com> Date: Fri, 22 Aug 2025 14:52:04 +0200 Subject: [PATCH 0480/1075] Remove references to jsons `quirks_mode` It was removed in https://github.com/ruby/json/commit/7d2ad6d6556da03300a5aeadeeacaec563435773 and does nothing now --- .../test/core_ext/object/json_gem_encoding_test.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/activesupport/test/core_ext/object/json_gem_encoding_test.rb b/activesupport/test/core_ext/object/json_gem_encoding_test.rb index 56c5f81343d9d..638ec54d99389 100644 --- a/activesupport/test/core_ext/object/json_gem_encoding_test.rb +++ b/activesupport/test/core_ext/object/json_gem_encoding_test.rb @@ -45,7 +45,7 @@ def require_or_skip(file) def assert_same_with_or_without_active_support(subject) begin - expected = JSON.generate(subject, quirks_mode: true) + expected = JSON.generate(subject) rescue JSON::GeneratorError => e exception = e end @@ -54,10 +54,10 @@ def assert_same_with_or_without_active_support(subject) if exception assert_raises JSON::GeneratorError, match: e.message do - JSON.generate(subject, quirks_mode: true) + JSON.generate(subject) end else - assert_equal expected, JSON.generate(subject, quirks_mode: true) + assert_equal expected, JSON.generate(subject) end end end From 0e1984235417eee7fc88934aaadd93b50a78f554 Mon Sep 17 00:00:00 2001 From: "Ben Sheldon [he/him]" Date: Tue, 1 Jul 2025 13:54:11 -0700 Subject: [PATCH 0481/1075] Allow `current_page?` to match against specific HTTP method(s) with a `method:` option --- actionview/CHANGELOG.md | 4 +++ .../lib/action_view/helpers/url_helper.rb | 33 ++++++++++++++++--- actionview/test/template/url_helper_test.rb | 21 ++++++++++++ 3 files changed, 53 insertions(+), 5 deletions(-) diff --git a/actionview/CHANGELOG.md b/actionview/CHANGELOG.md index f398dd4eff8b6..b1ea8f0d6f72b 100644 --- a/actionview/CHANGELOG.md +++ b/actionview/CHANGELOG.md @@ -1,3 +1,7 @@ +* Allow `current_page?` to match against specific HTTP method(s) with a `method:` option. + + *Ben Sheldon* + * remove `autocomplete="off"` on hidden inputs generated by `form_tag`, `token_tag`, `method_tag`, and the hidden parameter fields included in `button_to` forms will omit the `autocomplete="off"` attribute. diff --git a/actionview/lib/action_view/helpers/url_helper.rb b/actionview/lib/action_view/helpers/url_helper.rb index 06d3297558e1a..f305c11a275ad 100644 --- a/actionview/lib/action_view/helpers/url_helper.rb +++ b/actionview/lib/action_view/helpers/url_helper.rb @@ -539,24 +539,47 @@ def mail_to(email_address, name = nil, html_options = {}, &block) # current_page?('http://www.example.com/shop/checkout?order=desc&page=1') # # => true # - # Let's say we're in the http://www.example.com/products action with method POST in case of invalid product. + # Different actions may share the same URL path but have a different HTTP method. Let's say we + # sent a POST to http://www.example.com/products and rendered a validation error. # # current_page?(controller: 'product', action: 'index') # # => false # + # current_page?(controller: 'product', action: 'create') + # # => false + # + # current_page?(controller: 'product', action: 'create', method: :post) + # # => true + # + # current_page?(controller: 'product', action: 'index', method: [:get, :post]) + # # => true + # # We can also pass in the symbol arguments instead of strings. # - def current_page?(options = nil, check_parameters: false, **options_as_kwargs) + def current_page?(options = nil, check_parameters: false, method: :get, **options_as_kwargs) unless request raise "You cannot use helpers that need to determine the current " \ "page unless your view context provides a Request object " \ "in a #request method" end - return false unless request.get? || request.head? + if options.is_a?(Hash) + check_parameters = options.delete(:check_parameters) { check_parameters } + method = options.delete(:method) { method } + else + options ||= options_as_kwargs + end + + method_matches = case method + when :get + request.get? || request.head? + when Array + method.include?(request.method_symbol) || (method.include?(:get) && request.head?) + else + method == request.method_symbol + end + return false unless method_matches - options ||= options_as_kwargs - check_parameters ||= options.is_a?(Hash) && options.delete(:check_parameters) url_string = URI::RFC2396_PARSER.unescape(url_for(options)).force_encoding(Encoding::BINARY) # We ignore any extra parameters in the request_uri if the diff --git a/actionview/test/template/url_helper_test.rb b/actionview/test/template/url_helper_test.rb index f8b2982a58912..200ceb6b89be3 100644 --- a/actionview/test/template/url_helper_test.rb +++ b/actionview/test/template/url_helper_test.rb @@ -779,6 +779,27 @@ def test_current_page_with_not_get_verb @request = request_for_url("/events", method: :post) assert_not current_page?("/events") + assert current_page?("/events", method: :post) + end + + def test_current_page_with_array_of_methods_including_request_method + @request = request_for_url("/events", method: :post) + + assert current_page?("/events", method: [:post, :put, :delete]) + assert_not current_page?("/events", method: [:put, :delete]) + end + + def test_current_page_with_array_of_methods_including_get_method_includes_head + @request = request_for_url("/events", method: :head) + + assert current_page?("/events", method: [:post, :get]) + end + + def test_current_page_preserves_method_param_in_url + @request = request_for_url("/events?method=post", method: :put) + + assert current_page?("/events?method=post", method: :put) + assert_not current_page?("/events?method=post", method: :post) end def test_link_unless_current From a68af34168677c937065e0dc9831a8fb37c3969d Mon Sep 17 00:00:00 2001 From: Kristian Gerardsson Date: Sun, 24 Aug 2025 00:03:12 +0800 Subject: [PATCH 0482/1075] Dark mode support in the welcome page --- .../rails/templates/rails/welcome/index.html.erb | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/railties/lib/rails/templates/rails/welcome/index.html.erb b/railties/lib/rails/templates/rails/welcome/index.html.erb index 1e11eff826dc7..a9befa4be9339 100644 --- a/railties/lib/rails/templates/rails/welcome/index.html.erb +++ b/railties/lib/rails/templates/rails/welcome/index.html.erb @@ -35,6 +35,14 @@ text-align: center; } + @media (prefers-color-scheme: dark) { + body { + background-color: #1a1a1a; + background-image: url(data:image/svg+xml;base64,PHN2ZyBoZWlnaHQ9IjEwMjQiIHZpZXdCb3g9IjAgMCAxNDQwIDEwMjQiIHdpZHRoPSIxNDQwIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxwYXRoIGQ9Im0xNDQwIDUxMC4wMDA2NDh2LTUxMC4wMDA2NDhoLTE0NDB2Mzg0LjAwMDY0OGM0MTcuMzExOTM5IDEzMS4xNDIxNzkgODkxIDE3MS41MTMgMTQ0MCAxMjZ6IiBmaWxsPSIjMzMzIi8+PC9zdmc+); + color: #e0e0e0; + } + } + nav { font-size: 0; height: 20vw; @@ -58,6 +66,13 @@ background: #261B23; } + @media (prefers-color-scheme: dark) { + nav a { + background: #D30001; + filter: drop-shadow(0 20px 13px rgb(255 255 255 / 0.03)) drop-shadow(0 8px 5px rgb(255 255 255 / 0.08)); + } + } + nav a img { height: auto; max-width: 100%; From 30190c03e74a2c91d9a1079fa4251be0b19330ae Mon Sep 17 00:00:00 2001 From: Tim Tilberg Date: Sat, 23 Aug 2025 13:44:42 -0500 Subject: [PATCH 0483/1075] Correct irb inspection class [ci skip] --- guides/source/plugins.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/guides/source/plugins.md b/guides/source/plugins.md index a235f62e6b7e2..9a8c9e3a4aad3 100644 --- a/guides/source/plugins.md +++ b/guides/source/plugins.md @@ -287,7 +287,7 @@ $ bin/rails console ```irb irb> 10.requests_per_hour -=> # +=> # ``` The dummy application automatically loads your plugin, so any extensions you add From c5196d9873d3f12fb41f280d63095951db407e7a Mon Sep 17 00:00:00 2001 From: Kir Shatrov Date: Sat, 23 Aug 2025 21:38:27 -0700 Subject: [PATCH 0484/1075] Allow nested transaction with the same explicitly passed isolation level --- .../abstract/database_statements.rb | 2 +- .../test/cases/transaction_isolation_test.rb | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) 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 e3b4fdb0e1832..712fc444a6fbd 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb @@ -354,7 +354,7 @@ def truncate_tables(*table_names) # :nodoc: # :args: (requires_new: nil, isolation: nil, &block) def transaction(requires_new: nil, isolation: nil, joinable: true, &block) if !requires_new && current_transaction.joinable? - if isolation + if isolation && current_transaction.isolation != isolation raise ActiveRecord::TransactionIsolationError, "cannot set isolation when joining a transaction" end yield current_transaction.user_transaction diff --git a/activerecord/test/cases/transaction_isolation_test.rb b/activerecord/test/cases/transaction_isolation_test.rb index 61e013c024c57..70b1a82482e6b 100644 --- a/activerecord/test/cases/transaction_isolation_test.rb +++ b/activerecord/test/cases/transaction_isolation_test.rb @@ -211,6 +211,18 @@ class Dog < ARUnit2Model assert_begin_isolation_level_event(events, isolation: "REPEATABLE READ") end + test "specifying the same isolation level should not raise an error" do + assert_nothing_raised do + Tag.transaction(isolation: :read_committed) do + Tag.create! + + Tag.transaction(isolation: :read_committed) do + Tag.create! + end + end + end + end + # We are testing that a nonrepeatable read does not happen if ActiveRecord::Base.lease_connection.transaction_isolation_levels.include?(:repeatable_read) test "repeatable read" do From 9de8a7c3598ba6a82774eb9658cc50e9d673da52 Mon Sep 17 00:00:00 2001 From: a5-stable Date: Sun, 24 Aug 2025 15:46:31 +0900 Subject: [PATCH 0485/1075] no need for filter_parameters in base model --- activerecord/lib/active_record/filter_attribute_handler.rb | 2 +- railties/test/application/active_record_railtie_test.rb | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/activerecord/lib/active_record/filter_attribute_handler.rb b/activerecord/lib/active_record/filter_attribute_handler.rb index d700e103bbf27..ee03ff361bfd6 100644 --- a/activerecord/lib/active_record/filter_attribute_handler.rb +++ b/activerecord/lib/active_record/filter_attribute_handler.rb @@ -62,7 +62,7 @@ def collect_for_later(klass, list) def apply_filter(klass, list) list.each do |attribute| - next if klass.abstract_class? + next if klass.abstract_class? || klass == Base klass_name = klass.name ? klass.model_name.element : nil filter = [klass_name, attribute.to_s].compact.join(".") diff --git a/railties/test/application/active_record_railtie_test.rb b/railties/test/application/active_record_railtie_test.rb index a4704fc5f7694..72a097acc0e49 100644 --- a/railties/test/application/active_record_railtie_test.rb +++ b/railties/test/application/active_record_railtie_test.rb @@ -71,6 +71,8 @@ class Message < ActiveRecord::Base test "filter_parameters are inherited from AR parent classes" do app "development" + ActiveRecord::Base.filter_attributes += [ "expires_at" ] + class ApplicationRecord < ActiveRecord::Base self.abstract_class = true self.filter_attributes += [ "expires_at" ] @@ -84,6 +86,7 @@ class CreditCard < ApplicationRecord assert_includes Rails.application.config.filter_parameters, "credit_card.expires_at" assert_includes Rails.application.config.filter_parameters, "credit_card.digits" assert_not_includes Rails.application.config.filter_parameters, "application_record.expires_at" + assert_not_includes Rails.application.config.filter_parameters, "base.expires_at" end end end From 05bcad6c657aeae2923725ab5fcbe0f7cbb4e503 Mon Sep 17 00:00:00 2001 From: Earlopain <14981592+Earlopain@users.noreply.github.com> Date: Sun, 24 Aug 2025 11:03:50 +0200 Subject: [PATCH 0486/1075] Slightly speed up ActiveJob enqueueing with hashes/keyword arguments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The splat in the case/when is not great since it allocates an array each time it is encountered. This avoids the allocation, uses a set for the reserved keys, and avoids string allocations for symbol keys. Benchmark: ```rb require "bundler/inline" gemfile(true) do source "https://rubygems.org" gem "rails", path: "../rails" gem "benchmark-ips" end require "active_job/railtie" class TestApp < Rails::Application config.load_defaults Rails::VERSION::STRING.to_f config.eager_load = false config.secret_key_base = "secret_key_base" config.active_job.queue_adapter = :test config.logger = Logger.new(File::NULL) end Rails.application.initialize! class MyJob < ActiveJob::Base def perform puts "performed" end end job_data = ("a".."z").to_h { [it, it] } Benchmark.ips do |x| x.report("perform_later") do MyJob.perform_later(job_data) end end ``` Before: ``` ruby 3.4.3 (2025-04-14 revision d0b7e5b6a0) +PRISM [x86_64-linux] Warming up -------------------------------------- perform_later 1.541k i/100ms Calculating ------------------------------------- perform_later 16.415k (±18.0%) i/s (60.92 μs/i) - 78.591k in 5.031642s ``` After: ``` ruby 3.4.3 (2025-04-14 revision d0b7e5b6a0) +PRISM [x86_64-linux] Warming up -------------------------------------- perform_later 1.834k i/100ms Calculating ------------------------------------- perform_later 19.456k (±20.3%) i/s (51.40 μs/i) - 91.700k in 5.028421s ``` About 18% faster when the hash has many string keys Co-Authored-By: Jean Boussier --- activejob/lib/active_job/arguments.rb | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/activejob/lib/active_job/arguments.rb b/activejob/lib/active_job/arguments.rb index 2da3337695df1..dbfc032d2eea1 100644 --- a/activejob/lib/active_job/arguments.rb +++ b/activejob/lib/active_job/arguments.rb @@ -64,7 +64,7 @@ def deserialize(arguments) RUBY2_KEYWORDS_KEY, RUBY2_KEYWORDS_KEY.to_sym, OBJECT_SERIALIZER_KEY, OBJECT_SERIALIZER_KEY.to_sym, WITH_INDIFFERENT_ACCESS_KEY, WITH_INDIFFERENT_ACCESS_KEY.to_sym, - ] + ].to_set private_constant :RESERVED_KEYS, :GLOBALID_KEY, :SYMBOL_KEYS_KEY, :RUBY2_KEYWORDS_KEY, :WITH_INDIFFERENT_ACCESS_KEY @@ -159,10 +159,12 @@ def deserialize_hash(serialized_hash) def serialize_hash_key(key) case key - when *RESERVED_KEYS + when RESERVED_KEYS raise SerializationError.new("Can't serialize a Hash with reserved key #{key.inspect}") - when String, Symbol - key.to_s + when String + key + when Symbol + key.name else raise SerializationError.new("Only string and symbol hash keys may be serialized as job arguments, but #{key.inspect} is a #{key.class}") end From 9878f0f01576ccef4841cc08f00989ceb81db10a Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Sun, 24 Aug 2025 11:49:13 +0200 Subject: [PATCH 0487/1075] Optimize ActiveJob symbol serialization Leverae Symbol#name to save a few allocations. --- activejob/lib/active_job/arguments.rb | 5 ++++- activejob/lib/active_job/serializers/symbol_serializer.rb | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/activejob/lib/active_job/arguments.rb b/activejob/lib/active_job/arguments.rb index dbfc032d2eea1..9094a7bceecb0 100644 --- a/activejob/lib/active_job/arguments.rb +++ b/activejob/lib/active_job/arguments.rb @@ -89,7 +89,10 @@ def serialize_argument(argument) when ActiveSupport::HashWithIndifferentAccess serialize_indifferent_hash(argument) when Hash - symbol_keys = argument.each_key.grep(Symbol).map!(&:to_s) + symbol_keys = argument.keys + symbol_keys.select! { |k| k.is_a?(Symbol) } + symbol_keys.map!(&:name) + aj_hash_key = if Hash.ruby2_keywords_hash?(argument) RUBY2_KEYWORDS_KEY else diff --git a/activejob/lib/active_job/serializers/symbol_serializer.rb b/activejob/lib/active_job/serializers/symbol_serializer.rb index 3f6ee9101f56e..7db7f3bbf837a 100644 --- a/activejob/lib/active_job/serializers/symbol_serializer.rb +++ b/activejob/lib/active_job/serializers/symbol_serializer.rb @@ -4,7 +4,7 @@ module ActiveJob module Serializers class SymbolSerializer < ObjectSerializer # :nodoc: def serialize(argument) - super("value" => argument.to_s) + super("value" => argument.name) end def deserialize(argument) From 7d420ba613f7401c2eb9eb826700f14023bd7741 Mon Sep 17 00:00:00 2001 From: Earlopain <14981592+Earlopain@users.noreply.github.com> Date: Sun, 24 Aug 2025 16:37:32 +0200 Subject: [PATCH 0488/1075] Report json parse errors during json deserialization Since `json` [v2.13.0](https://github.com/ruby/json/releases/tag/v2.13.0), duplicate keys are warned during parsing with the goal of raising in 3.0.0 For rails, I encountered this with json column types that merge into themselves. On rails below 8.1, this shows the warning message from the json gem. On main however, the output is silent since https://github.com/rails/rails/pull/54748 ```rb require "bundler/inline" gemfile(true) do source "https://rubygems.org" gem "rails", "~> 8.0.0" gem "sqlite3" end require "active_record/railtie" require "minitest/autorun" ENV["DATABASE_URL"] = "sqlite3::memory:" class TestApp < Rails::Application config.load_defaults Rails::VERSION::STRING.to_f config.eager_load = false config.logger = Logger.new($stdout) config.secret_key_base = "secret_key_base" config.active_record.encryption.primary_key = "primary_key" config.active_record.encryption.deterministic_key = "deterministic_key" config.active_record.encryption.key_derivation_salt = "key_derivation_salt" end Rails.application.initialize! ActiveRecord::Schema.define do create_table :posts, force: true do |t| t.json :payload end end class Post < ActiveRecord::Base end class BugTest < ActiveSupport::TestCase def test_association_stuff post = Post.create!(payload: { foo: "bar" }) [1, 2].each do |i| assert_silent do post.update(payload: post.payload.merge(state: i)) end end assert_equal({ "foo" => "bar", "state" => 2 }, post.payload) end end ``` --- activerecord/lib/active_record/type/json.rb | 11 ++++++++++- .../test/cases/adapters/sqlite3/json_test.rb | 14 ++++++++++++++ activerecord/test/cases/json_attribute_test.rb | 14 ++++++++++++++ 3 files changed, 38 insertions(+), 1 deletion(-) diff --git a/activerecord/lib/active_record/type/json.rb b/activerecord/lib/active_record/type/json.rb index d49c2d27e00e1..ab2f5f7bec1c1 100644 --- a/activerecord/lib/active_record/type/json.rb +++ b/activerecord/lib/active_record/type/json.rb @@ -13,7 +13,16 @@ def type def deserialize(value) return value unless value.is_a?(::String) - ActiveSupport::JSON.decode(value) rescue nil + begin + ActiveSupport::JSON.decode(value) + rescue JSON::ParserError => e + # NOTE: This may hide json with duplicate keys. We don't really want to just ignore it + # but it's the best we can do in order to still allow updating columns that somehow already + # contain invalid json from some other source. + # See https://github.com/rails/rails/pull/55536 + ActiveSupport.error_reporter.report(e, source: "application.active_record") + nil + end end JSON_ENCODER = ActiveSupport::JSON::Encoding.json_encoder.new(escape: false) diff --git a/activerecord/test/cases/adapters/sqlite3/json_test.rb b/activerecord/test/cases/adapters/sqlite3/json_test.rb index bbbefe61e6022..7aaa3f4b96af7 100644 --- a/activerecord/test/cases/adapters/sqlite3/json_test.rb +++ b/activerecord/test/cases/adapters/sqlite3/json_test.rb @@ -27,6 +27,20 @@ def test_default_before_type_cast assert_equal '{"list":[]}', klass.new.with_defaults_before_type_cast end + def test_invalid_json_can_be_updated + model = klass.create! + @connection.execute("UPDATE #{klass.table_name} SET payload = '---'") + + model.reload + assert_equal "---", model.payload_before_type_cast + assert_error_reported(JSON::ParserError) do + assert_nil model.payload + end + + model.update(payload: "no longer invalid") + assert_equal("no longer invalid", model.payload) + end + private def column_type :json diff --git a/activerecord/test/cases/json_attribute_test.rb b/activerecord/test/cases/json_attribute_test.rb index 02e0fa1f74a89..e151daff71d3c 100644 --- a/activerecord/test/cases/json_attribute_test.rb +++ b/activerecord/test/cases/json_attribute_test.rb @@ -25,6 +25,20 @@ def setup end end + def test_invalid_json_can_be_updated + model = klass.create! + @connection.execute("UPDATE #{klass.table_name} SET payload = '---'") + + model.reload + assert_equal "---", model.payload_before_type_cast + assert_error_reported(JSON::ParserError) do + assert_nil model.payload + end + + model.update(payload: "no longer invalid") + assert_equal("no longer invalid", model.payload) + end + private def column_type :string From 8835117c0a643d631d112ca0fdd4e0ff64722dc4 Mon Sep 17 00:00:00 2001 From: Ryuta Kamizono Date: Sun, 24 Aug 2025 19:42:41 +0900 Subject: [PATCH 0489/1075] Add ActiveRecord::ExclusionViolation error class for exclusion constraint violations When an exclusion constraint is violated in PostgreSQL, the error will now be raised as ActiveRecord::ExclusionViolation instead of the generic ActiveRecord::StatementInvalid, making it easier to handle these specific constraint violations in application code. This follows the same pattern as other constraint violation error classes like RecordNotUnique for unique constraint violations and InvalidForeignKey for foreign key constraint violations. --- activerecord/CHANGELOG.md | 12 ++++++++++++ .../connection_adapters/postgresql_adapter.rb | 3 +++ activerecord/lib/active_record/errors.rb | 4 ++++ .../cases/migration/exclusion_constraint_test.rb | 15 +++++++++++++-- 4 files changed, 32 insertions(+), 2 deletions(-) diff --git a/activerecord/CHANGELOG.md b/activerecord/CHANGELOG.md index 73b868cfc6064..d6cbd66f111ff 100644 --- a/activerecord/CHANGELOG.md +++ b/activerecord/CHANGELOG.md @@ -1,3 +1,15 @@ +* Add `ActiveRecord::ExclusionViolation` error class for exclusion constraint violations. + + When an exclusion constraint is violated in PostgreSQL, the error will now be raised + as `ActiveRecord::ExclusionViolation` instead of the generic `ActiveRecord::StatementInvalid`, + making it easier to handle these specific constraint violations in application code. + + This follows the same pattern as other constraint violation error classes like + `RecordNotUnique` for unique constraint violations and `InvalidForeignKey` for + foreign key constraint violations. + + *Ryuta Kamizono* + * Attributes filtered by `filter_attributes` will now also be filtered by `filter_parameters` so sensitive information is not leaked. diff --git a/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb b/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb index 7ce16386d7d84..b9c320b3cc7f4 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb @@ -791,6 +791,7 @@ def has_default_function?(default_value, default) NOT_NULL_VIOLATION = "23502" FOREIGN_KEY_VIOLATION = "23503" UNIQUE_VIOLATION = "23505" + EXCLUSION_VIOLATION = "23P01" SERIALIZATION_FAILURE = "40001" DEADLOCK_DETECTED = "40P01" DUPLICATE_DATABASE = "42P04" @@ -822,6 +823,8 @@ def translate_exception(exception, message:, sql:, binds:) RecordNotUnique.new(message, sql: sql, binds: binds, connection_pool: @pool) when FOREIGN_KEY_VIOLATION InvalidForeignKey.new(message, sql: sql, binds: binds, connection_pool: @pool) + when EXCLUSION_VIOLATION + ExclusionViolation.new(message, sql: sql, binds: binds, connection_pool: @pool) when VALUE_LIMIT_VIOLATION ValueTooLong.new(message, sql: sql, binds: binds, connection_pool: @pool) when NUMERIC_VALUE_OUT_OF_RANGE diff --git a/activerecord/lib/active_record/errors.rb b/activerecord/lib/active_record/errors.rb index d9ef25c86fd31..ab175ef6d05b4 100644 --- a/activerecord/lib/active_record/errors.rb +++ b/activerecord/lib/active_record/errors.rb @@ -292,6 +292,10 @@ def set_query(sql, binds) class NotNullViolation < StatementInvalid end + # Raised when a record cannot be inserted or updated because it would violate an exclusion constraint. + class ExclusionViolation < StatementInvalid + end + # Raised when a record cannot be inserted or updated because a value too long for a column type. class ValueTooLong < StatementInvalid end diff --git a/activerecord/test/cases/migration/exclusion_constraint_test.rb b/activerecord/test/cases/migration/exclusion_constraint_test.rb index ac40aafdf883e..81060d65cc462 100644 --- a/activerecord/test/cases/migration/exclusion_constraint_test.rb +++ b/activerecord/test/cases/migration/exclusion_constraint_test.rb @@ -144,7 +144,7 @@ def test_added_exclusion_constraint_ensures_valid_values Invoice.create(start_date: "2020-01-01", end_date: "2021-01-01") - assert_raises(ActiveRecord::StatementInvalid) do + assert_raises(ActiveRecord::ExclusionViolation) do Invoice.create(start_date: "2020-12-31", end_date: "2021-01-01") end end @@ -154,7 +154,7 @@ def test_added_deferrable_initially_immediate_exclusion_constraint invoice = Invoice.create(start_date: "2020-01-01", end_date: "2021-01-01") - assert_raises(ActiveRecord::StatementInvalid) do + assert_raises(ActiveRecord::ExclusionViolation) do Invoice.transaction(requires_new: true) do Invoice.create!(start_date: "2020-12-31", end_date: "2021-01-01") end @@ -186,6 +186,17 @@ def test_remove_non_existing_exclusion_constraint @connection.remove_exclusion_constraint :invoices, name: "nonexistent" end end + + def test_exclusion_constraint_violation_on_update + @connection.add_exclusion_constraint :invoices, "daterange(start_date, end_date) WITH &&", using: :gist, name: "invoices_date_overlap" + + Invoice.create(start_date: "2020-01-01", end_date: "2021-01-01") + invoice = Invoice.create(start_date: "2022-01-01", end_date: "2023-01-01") + + assert_raises(ActiveRecord::ExclusionViolation) do + invoice.update(start_date: "2020-12-31", end_date: "2021-01-01") + end + end end end end From 3df37617fa794f08bdb9e3212f8abb67541776bc Mon Sep 17 00:00:00 2001 From: Joshua Young Date: Sun, 24 Aug 2025 13:05:23 +1000 Subject: [PATCH 0490/1075] [Fix #55525] Address deprecation of `Aws::S3::Object#upload_stream` in `ActiveStorage::Service::S3Service` --- .../lib/active_storage/service/s3_service.rb | 30 +++++++++++++++---- 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/activestorage/lib/active_storage/service/s3_service.rb b/activestorage/lib/active_storage/service/s3_service.rb index 2f1c124f67cbc..d6b69467b5224 100644 --- a/activestorage/lib/active_storage/service/s3_service.rb +++ b/activestorage/lib/active_storage/service/s3_service.rb @@ -11,11 +11,13 @@ module ActiveStorage # Wraps the Amazon Simple Storage Service (S3) as an Active Storage service. # See ActiveStorage::Service for the generic API documentation that applies to all services. class Service::S3Service < Service - attr_reader :client, :bucket + attr_reader :client, :transfer_manager, :bucket attr_reader :multipart_upload_threshold, :upload_options def initialize(bucket:, upload: {}, public: false, **options) @client = Aws::S3::Resource.new(**options) + @s3_client = @client.client + @transfer_manager = Aws::S3::TransferManager.new(client: @s3_client) if defined?(Aws::S3::TransferManager) @bucket = @client.bucket(bucket) @multipart_upload_threshold = upload.delete(:multipart_threshold) || 100.megabytes @@ -100,13 +102,19 @@ def headers_for_direct_upload(key, content_type:, checksum:, filename: nil, disp def compose(source_keys, destination_key, filename: nil, content_type: nil, disposition: nil, custom_metadata: {}) content_disposition = content_disposition_with(type: disposition, filename: filename) if disposition && filename - object_for(destination_key).upload_stream( + upload_stream_options = { content_type: content_type, content_disposition: content_disposition, part_size: MINIMUM_UPLOAD_PART_SIZE, metadata: custom_metadata, **upload_options - ) do |out| + } + if transfer_manager + upload_stream_options[:bucket] = bucket.name + upload_stream_options[:key] = destination_key + end + + (transfer_manager || object_for(destination_key)).upload_stream(**upload_stream_options) do |out| source_keys.each do |source_key| stream(source_key) do |chunk| IO.copy_stream(StringIO.new(chunk), out) @@ -126,7 +134,6 @@ def public_url(key, **client_opts) object_for(key).public_url(**client_opts) end - MAXIMUM_UPLOAD_PARTS_COUNT = 10000 MINIMUM_UPLOAD_PART_SIZE = 5.megabytes @@ -139,12 +146,23 @@ def upload_with_single_part(key, io, checksum: nil, content_type: nil, content_d def upload_with_multipart(key, io, content_type: nil, content_disposition: nil, custom_metadata: {}) part_size = [ io.size.fdiv(MAXIMUM_UPLOAD_PARTS_COUNT).ceil, MINIMUM_UPLOAD_PART_SIZE ].max - object_for(key).upload_stream(content_type: content_type, content_disposition: content_disposition, part_size: part_size, metadata: custom_metadata, **upload_options) do |out| + upload_stream_options = { + content_type: content_type, + content_disposition: content_disposition, + part_size: part_size, + metadata: custom_metadata, + **upload_options + } + if transfer_manager + upload_stream_options[:bucket] = bucket.name + upload_stream_options[:key] = key + end + + (transfer_manager || object_for(key)).upload_stream(**upload_stream_options) do |out| IO.copy_stream(io, out) end end - def object_for(key) bucket.object(key) end From 415b8bcab8da45c466248d3e452c3a28ba7a3ce6 Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Sun, 24 Aug 2025 21:29:31 +0200 Subject: [PATCH 0491/1075] Refactor ActiveStorage S3Service Simplify conditionals. --- Gemfile.lock | 19 +++++----- .../lib/active_storage/service/s3_service.rb | 35 +++++++++---------- 2 files changed, 27 insertions(+), 27 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 5d1be453e1c46..7313d8b149f82 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -124,24 +124,27 @@ GEM public_suffix (>= 2.0.2, < 7.0) amq-protocol (2.3.2) ast (2.4.2) - aws-eventstream (1.3.0) - aws-partitions (1.1037.0) - aws-sdk-core (3.215.1) + aws-eventstream (1.4.0) + aws-partitions (1.1150.0) + aws-sdk-core (3.230.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) + base64 + bigdecimal jmespath (~> 1, >= 1.6.1) - aws-sdk-kms (1.96.0) - aws-sdk-core (~> 3, >= 3.210.0) + logger + aws-sdk-kms (1.110.0) + aws-sdk-core (~> 3, >= 3.228.0) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.177.0) - aws-sdk-core (~> 3, >= 3.210.0) + aws-sdk-s3 (1.197.0) + aws-sdk-core (~> 3, >= 3.228.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) aws-sdk-sns (1.92.0) aws-sdk-core (~> 3, >= 3.210.0) aws-sigv4 (~> 1.5) - aws-sigv4 (1.11.0) + aws-sigv4 (1.12.1) aws-eventstream (~> 1, >= 1.0.2) azure-storage-blob (2.0.3) azure-storage-common (~> 2.0) diff --git a/activestorage/lib/active_storage/service/s3_service.rb b/activestorage/lib/active_storage/service/s3_service.rb index d6b69467b5224..729cf50c25868 100644 --- a/activestorage/lib/active_storage/service/s3_service.rb +++ b/activestorage/lib/active_storage/service/s3_service.rb @@ -11,13 +11,12 @@ module ActiveStorage # Wraps the Amazon Simple Storage Service (S3) as an Active Storage service. # See ActiveStorage::Service for the generic API documentation that applies to all services. class Service::S3Service < Service - attr_reader :client, :transfer_manager, :bucket + attr_reader :client, :bucket attr_reader :multipart_upload_threshold, :upload_options def initialize(bucket:, upload: {}, public: false, **options) @client = Aws::S3::Resource.new(**options) - @s3_client = @client.client - @transfer_manager = Aws::S3::TransferManager.new(client: @s3_client) if defined?(Aws::S3::TransferManager) + @transfer_manager = Aws::S3::TransferManager.new(client: @client.client) if defined?(Aws::S3::TransferManager) @bucket = @client.bucket(bucket) @multipart_upload_threshold = upload.delete(:multipart_threshold) || 100.megabytes @@ -102,19 +101,14 @@ def headers_for_direct_upload(key, content_type:, checksum:, filename: nil, disp def compose(source_keys, destination_key, filename: nil, content_type: nil, disposition: nil, custom_metadata: {}) content_disposition = content_disposition_with(type: disposition, filename: filename) if disposition && filename - upload_stream_options = { + upload_stream( + key: destination_key, content_type: content_type, content_disposition: content_disposition, part_size: MINIMUM_UPLOAD_PART_SIZE, metadata: custom_metadata, **upload_options - } - if transfer_manager - upload_stream_options[:bucket] = bucket.name - upload_stream_options[:key] = destination_key - end - - (transfer_manager || object_for(destination_key)).upload_stream(**upload_stream_options) do |out| + ) do |out| source_keys.each do |source_key| stream(source_key) do |chunk| IO.copy_stream(StringIO.new(chunk), out) @@ -124,6 +118,14 @@ def compose(source_keys, destination_key, filename: nil, content_type: nil, disp end private + def upload_stream(key:, **options, &block) + if @transfer_manager + @transfer_manager.upload_stream(key: key, bucket: bucket.name, **options, &block) + else + object_for(key).upload_stream(**options, &block) + end + end + def private_url(key, expires_in:, filename:, disposition:, content_type:, **client_opts) object_for(key).presigned_url :get, expires_in: expires_in.to_i, response_content_disposition: content_disposition_with(type: disposition, filename: filename), @@ -146,19 +148,14 @@ def upload_with_single_part(key, io, checksum: nil, content_type: nil, content_d def upload_with_multipart(key, io, content_type: nil, content_disposition: nil, custom_metadata: {}) part_size = [ io.size.fdiv(MAXIMUM_UPLOAD_PARTS_COUNT).ceil, MINIMUM_UPLOAD_PART_SIZE ].max - upload_stream_options = { + upload_stream( + key: key, content_type: content_type, content_disposition: content_disposition, part_size: part_size, metadata: custom_metadata, **upload_options - } - if transfer_manager - upload_stream_options[:bucket] = bucket.name - upload_stream_options[:key] = key - end - - (transfer_manager || object_for(key)).upload_stream(**upload_stream_options) do |out| + ) do |out| IO.copy_stream(io, out) end end From c7529d38503c39fcb5c24ecabdac1350784ad10b Mon Sep 17 00:00:00 2001 From: zzak Date: Sun, 20 Jul 2025 10:55:08 +0900 Subject: [PATCH 0492/1075] Allow unsubscribing from events --- .../lib/active_support/event_reporter.rb | 20 +++++++++++--- activesupport/test/event_reporter_test.rb | 27 ++++++++++++++++--- 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/activesupport/lib/active_support/event_reporter.rb b/activesupport/lib/active_support/event_reporter.rb index c0ad55a09fc9f..3ea0043e20b27 100644 --- a/activesupport/lib/active_support/event_reporter.rb +++ b/activesupport/lib/active_support/event_reporter.rb @@ -261,8 +261,9 @@ def clear # # payload: { id: 123 }, # # } class EventReporter - attr_writer :raise_on_error # :nodoc: - attr_reader :subscribers + # Sets whether to raise an error if a subscriber raises an error during + # event emission, or when unexpected arguments are passed to +notify+. + attr_writer :raise_on_error class << self attr_accessor :context_store # :nodoc: @@ -298,10 +299,21 @@ def subscribe(subscriber, &filter) unless subscriber.respond_to?(:emit) raise ArgumentError, "Event subscriber #{subscriber.class.name} must respond to #emit" end - @subscribers << { subscriber: subscriber, filter: filter } end + # Unregister an event subscriber. Accepts either a subscriber or a class. + # + # subscriber = MyEventSubscriber.new + # Rails.event.subscribe(subscriber) + # + # Rails.event.unsubscribe(subscriber) + # # or + # Rails.event.unsubscribe(MyEventSubscriber) + def unsubscribe(subscriber) + @subscribers.delete_if { |s| s[:subscriber] === subscriber } + end + # Reports an event to all registered subscribers. An event name and payload can be provided: # # Rails.event.notify("user.created", { id: 123 }) @@ -358,7 +370,7 @@ def notify(name_or_object, payload = nil, caller_depth: 1, **kwargs) event[:source_location] = source_location end - subscribers.each do |subscriber_entry| + @subscribers.each do |subscriber_entry| subscriber = subscriber_entry[:subscriber] filter = subscriber_entry[:filter] diff --git a/activesupport/test/event_reporter_test.rb b/activesupport/test/event_reporter_test.rb index 1f774ce3f41fd..17347ec31c438 100644 --- a/activesupport/test/event_reporter_test.rb +++ b/activesupport/test/event_reporter_test.rb @@ -48,17 +48,17 @@ def emit(event) test "#subscribe" do reporter = ActiveSupport::EventReporter.new - reporter.subscribe(@subscriber) - assert_equal([{ subscriber: @subscriber, filter: nil }], reporter.subscribers) + subscribers = reporter.subscribe(@subscriber) + assert_equal([{ subscriber: @subscriber, filter: nil }], subscribers) end test "#subscribe with filter" do reporter = ActiveSupport::EventReporter.new filter = ->(event) { event[:name].start_with?("user.") } - reporter.subscribe(@subscriber, &filter) + subscribers = reporter.subscribe(@subscriber, &filter) - assert_equal([{ subscriber: @subscriber, filter: filter }], reporter.subscribers) + assert_equal([{ subscriber: @subscriber, filter: filter }], subscribers) end test "#subscribe raises ArgumentError when sink doesn't respond to emit" do @@ -71,6 +71,25 @@ def emit(event) assert_equal "Event subscriber Object must respond to #emit", error.message end + test "#unsubscribe" do + second_subscriber = EventSubscriber.new + @reporter.subscribe(second_subscriber) + @reporter.notify(:test_event, key: "value") + + assert event_matcher(name: "test_event", payload: { key: "value" }).call(second_subscriber.events.last) + + @reporter.unsubscribe(second_subscriber) + + assert_not_called(second_subscriber, :emit, [ + event_matcher(name: "another_event") + ]) do + @reporter.notify(:another_event, key: "value") + end + + @reporter.notify(:last_event, key: "value") + assert_empty second_subscriber.events.select(&event_matcher(name: "last_event", payload: { key: "value" })) + end + test "#notify with name" do assert_called_with(@subscriber, :emit, [ event_matcher(name: "test_event") From fb9b5d5e078e43ec7c93c2bcf8453d7306bea956 Mon Sep 17 00:00:00 2001 From: zzak Date: Sun, 20 Jul 2025 08:34:13 +0900 Subject: [PATCH 0493/1075] Filter event reporter payloads For event payloads that are a hash, filter the data before emitting the event. Since we want to filter any sensitive data as early as possible. However, custom objects are not resolved until later so we can't filter those. --- activesupport/lib/active_support.rb | 2 ++ .../lib/active_support/event_reporter.rb | 19 +++++++++++++++++-- activesupport/lib/active_support/railtie.rb | 6 ++++++ activesupport/test/event_reporter_test.rb | 11 +++++++++++ 4 files changed, 36 insertions(+), 2 deletions(-) diff --git a/activesupport/lib/active_support.rb b/activesupport/lib/active_support.rb index 5a2810c7928e9..f8ed8a835b364 100644 --- a/activesupport/lib/active_support.rb +++ b/activesupport/lib/active_support.rb @@ -114,6 +114,8 @@ def self.eager_load! @event_reporter = ActiveSupport::EventReporter.new singleton_class.attr_accessor :event_reporter # :nodoc: + cattr_accessor :filter_parameters, default: [] # :nodoc: + def self.cache_format_version Cache.format_version end diff --git a/activesupport/lib/active_support/event_reporter.rb b/activesupport/lib/active_support/event_reporter.rb index c0ad55a09fc9f..29f6de5bebfea 100644 --- a/activesupport/lib/active_support/event_reporter.rb +++ b/activesupport/lib/active_support/event_reporter.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "active_support/parameter_filter" + module ActiveSupport class TagStack # :nodoc: EMPTY_TAGS = {}.freeze @@ -260,6 +262,12 @@ def clear # # name: "user.created", # # payload: { id: 123 }, # # } + # + # ==== Security + # + # When reporting events, Hash-based payloads are automatically filtered to remove sensitive data based on {Rails.application.filter_parameters}[https://guides.rubyonrails.org/configuring.html#config-filter-parameters]. + # + # If an {event object}[rdoc-ref:EventReporter@Event+Objects] is given instead, subscribers will need to filter sensitive data themselves, e.g. with ActiveSupport::ParameterFilter. class EventReporter attr_writer :raise_on_error # :nodoc: attr_reader :subscribers @@ -512,6 +520,13 @@ def context_store self.class.context_store end + def payload_filter + @payload_filter ||= begin + mask = ActiveSupport::ParameterFilter::FILTERED + ActiveSupport::ParameterFilter.new(ActiveSupport.filter_parameters, mask: mask) + end + end + def resolve_name(name_or_object) case name_or_object when String, Symbol @@ -526,9 +541,9 @@ def resolve_payload(name_or_object, payload, **kwargs) when String, Symbol handle_unexpected_args(name_or_object, payload, kwargs) if payload && kwargs.any? if kwargs.any? - kwargs.transform_keys(&:to_sym) + payload_filter.filter(kwargs.transform_keys(&:to_sym)) elsif payload - payload.transform_keys(&:to_sym) + payload_filter.filter(payload.transform_keys(&:to_sym)) end else handle_unexpected_args(name_or_object, payload, kwargs) if payload || kwargs.any? diff --git a/activesupport/lib/active_support/railtie.rb b/activesupport/lib/active_support/railtie.rb index 4acf2766e4a84..183e83f0658c1 100644 --- a/activesupport/lib/active_support/railtie.rb +++ b/activesupport/lib/active_support/railtie.rb @@ -79,6 +79,12 @@ class Railtie < Rails::Railtie # :nodoc: end end + initializer "active_support.set_filter_parameters" do |app| + config.after_initialize do + ActiveSupport.filter_parameters += Rails.application.config.filter_parameters + end + end + initializer "active_support.deprecation_behavior" do |app| if app.config.active_support.report_deprecations == false app.deprecators.silenced = true diff --git a/activesupport/test/event_reporter_test.rb b/activesupport/test/event_reporter_test.rb index 1f774ce3f41fd..4ee1562b7d18a 100644 --- a/activesupport/test/event_reporter_test.rb +++ b/activesupport/test/event_reporter_test.rb @@ -222,6 +222,17 @@ def emit(event) assert_equal("Uh oh!", error.message) end + test "#notify with filtered payloads" do + filter = ActiveSupport::ParameterFilter.new([:zomg], mask: "[FILTERED]") + @reporter.stub(:payload_filter, filter) do + assert_called_with(@subscriber, :emit, [ + event_matcher(name: "test_event", payload: { key: "value", zomg: "[FILTERED]" }) + ]) do + @reporter.notify(:test_event, { key: "value", zomg: "secret" }) + end + end + end + test "#with_debug" do @reporter.with_debug do assert_predicate @reporter, :debug_mode? From fee614c5c40dc60738e241ca3918680f0f40f77a Mon Sep 17 00:00:00 2001 From: Petrik Date: Mon, 25 Aug 2025 13:15:31 +0200 Subject: [PATCH 0494/1075] Add headers to engine route inspection command When running `rails routes`, the routes of engines are shown in a separate block. This block currently doesn't show the headers for the engine routes. The existing headers for all the routes will typically have different indentation. Before: ``` Routes for Blorgh::Engine: articles GET /articles(.:format) blorgh/articles#index ... ``` After: ``` Routes for Blorgh::Engine: Prefix Verb URI Pattern Controller#Action articles GET /articles(.:format) blorgh/articles#index ... ``` --- actionpack/CHANGELOG.md | 4 ++++ actionpack/lib/action_dispatch/routing/inspector.rb | 5 ++++- actionpack/test/dispatch/routing/inspector_test.rb | 1 + 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/actionpack/CHANGELOG.md b/actionpack/CHANGELOG.md index 8158e563c3beb..83963324dec9c 100644 --- a/actionpack/CHANGELOG.md +++ b/actionpack/CHANGELOG.md @@ -1,3 +1,7 @@ +* Add headers to engine routes inspection command + + *Petrik de Heus* + * Add "Copy as text" button to error pages *Mikkel Malmberg* diff --git a/actionpack/lib/action_dispatch/routing/inspector.rb b/actionpack/lib/action_dispatch/routing/inspector.rb index c06a0053c8d76..b72c593af3cbc 100644 --- a/actionpack/lib/action_dispatch/routing/inspector.rb +++ b/actionpack/lib/action_dispatch/routing/inspector.rb @@ -89,7 +89,10 @@ def format(formatter, filter = {}) @engines.each do |name, engine_routes| formatter.section_title "Routes for #{name}" - formatter.section engine_routes + if engine_routes.any? + formatter.header engine_routes + formatter.section engine_routes + end end formatter.result diff --git a/actionpack/test/dispatch/routing/inspector_test.rb b/actionpack/test/dispatch/routing/inspector_test.rb index ccd862e46a42d..80049c27ac235 100644 --- a/actionpack/test/dispatch/routing/inspector_test.rb +++ b/actionpack/test/dispatch/routing/inspector_test.rb @@ -41,6 +41,7 @@ def self.inspect " blog /blog Blog::Engine", "", "Routes for Blog::Engine:", + "Prefix Verb URI Pattern Controller#Action", " cart GET /cart(.:format) cart#show" ], output end From a017e649fbd824a737e4ccba02999ca62a6a4a88 Mon Sep 17 00:00:00 2001 From: Shinichi Maeshima Date: Mon, 25 Aug 2025 23:08:26 +0900 Subject: [PATCH 0495/1075] [ci skip]Fix a broken link to ActiveStorage::Blob#url in the documentation ref: https://edgeapi.rubyonrails.org/classes/ActiveStorage/Variant.html#method-i-url --- activestorage/app/models/active_storage/variant.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/activestorage/app/models/active_storage/variant.rb b/activestorage/app/models/active_storage/variant.rb index 960b186d5d393..74c3084a8e96d 100644 --- a/activestorage/app/models/active_storage/variant.rb +++ b/activestorage/app/models/active_storage/variant.rb @@ -74,7 +74,7 @@ def key "variants/#{blob.key}/#{OpenSSL::Digest::SHA256.hexdigest(variation.key)}" end - # Returns the URL of the blob variant on the service. See {ActiveStorage::Blob#url} for details. + # Returns the URL of the blob variant on the service. See ActiveStorage::Blob#url for details. # # Use url_for(variant) (or the implied form, like link_to variant or redirect_to variant) to get the stable URL # for a variant that points to the ActiveStorage::RepresentationsController, which in turn will use this +service_call+ method From 59d4b71a3f44ac2834f26e85d2123705ba8d24fc Mon Sep 17 00:00:00 2001 From: Gannon McGibbon Date: Fri, 22 Aug 2025 00:40:45 -0500 Subject: [PATCH 0496/1075] Make public editing methods for ActiveSupport::Cache::Strategy::LocalCache Exposes methods to create a new local cache, clear the local cache, and get the current local cache. --- .../cache/strategy/local_cache.rb | 23 ++++++--- .../cache/strategy/local_cache_middleware.rb | 13 +++-- .../test/cache/local_cache_middleware_test.rb | 48 ++++++++++--------- 3 files changed, 48 insertions(+), 36 deletions(-) diff --git a/activesupport/lib/active_support/cache/strategy/local_cache.rb b/activesupport/lib/active_support/cache/strategy/local_cache.rb index d40d5efd44be6..b306b3392c03d 100644 --- a/activesupport/lib/active_support/cache/strategy/local_cache.rb +++ b/activesupport/lib/active_support/cache/strategy/local_cache.rb @@ -68,12 +68,25 @@ def with_local_cache(&block) use_temporary_local_cache(LocalStore.new, &block) end + # Set a new local cache. + def new_local_cache + LocalCacheRegistry.set_cache_for(local_cache_key, LocalStore.new) + end + + # Unset the current local cache. + def unset_local_cache + LocalCacheRegistry.set_cache_for(local_cache_key, nil) + end + + # The current local cache. + def local_cache + LocalCacheRegistry.cache_for(local_cache_key) + end + # Middleware class can be inserted as a Rack handler to be local cache for the # duration of request. def middleware - @middleware ||= Middleware.new( - "ActiveSupport::Cache::Strategy::LocalCache", - local_cache_key) + @middleware ||= Middleware.new("ActiveSupport::Cache::Strategy::LocalCache", self) end def clear(options = nil) # :nodoc: @@ -214,10 +227,6 @@ def local_cache_key @local_cache_key ||= "#{self.class.name.underscore}_local_cache_#{object_id}".gsub(/[\/-]/, "_").to_sym end - def local_cache - LocalCacheRegistry.cache_for(local_cache_key) - end - def bypass_local_cache(&block) use_temporary_local_cache(nil, &block) end diff --git a/activesupport/lib/active_support/cache/strategy/local_cache_middleware.rb b/activesupport/lib/active_support/cache/strategy/local_cache_middleware.rb index 62542bdb22428..ef471f2d880dc 100644 --- a/activesupport/lib/active_support/cache/strategy/local_cache_middleware.rb +++ b/activesupport/lib/active_support/cache/strategy/local_cache_middleware.rb @@ -11,11 +11,11 @@ module LocalCache # This class wraps up local storage for middlewares. Only the middleware method should # construct them. class Middleware # :nodoc: - attr_reader :name, :local_cache_key + attr_reader :name, :cache - def initialize(name, local_cache_key) + def initialize(name, cache) @name = name - @local_cache_key = local_cache_key + @cache = cache @app = nil end @@ -25,18 +25,17 @@ def new(app) end def call(env) - LocalCacheRegistry.set_cache_for(local_cache_key, LocalStore.new) + cache.new_local_cache response = @app.call(env) response[2] = ::Rack::BodyProxy.new(response[2]) do - LocalCacheRegistry.set_cache_for(local_cache_key, nil) + cache.unset_local_cache end cleanup_on_body_close = true response rescue Rack::Utils::InvalidParameterError [400, {}, []] ensure - LocalCacheRegistry.set_cache_for(local_cache_key, nil) unless - cleanup_on_body_close + cache.unset_local_cache unless cleanup_on_body_close end end end diff --git a/activesupport/test/cache/local_cache_middleware_test.rb b/activesupport/test/cache/local_cache_middleware_test.rb index c39e41832c8fe..db1cae0db8f1d 100644 --- a/activesupport/test/cache/local_cache_middleware_test.rb +++ b/activesupport/test/cache/local_cache_middleware_test.rb @@ -8,53 +8,57 @@ module Cache module Strategy module LocalCache class MiddlewareTest < ActiveSupport::TestCase + class Cache + include LocalCache + end + def test_local_cache_cleared_on_close - key = "super awesome key" - assert_nil LocalCacheRegistry.cache_for key - middleware = Middleware.new("<3", key).new(->(env) { - assert LocalCacheRegistry.cache_for(key), "should have a cache" + cache = Cache.new + assert_nil cache.local_cache + middleware = Middleware.new("<3", cache).new(->(env) { + assert cache.local_cache, "should have a cache" [200, {}, []] }) _, _, body = middleware.call({}) - assert LocalCacheRegistry.cache_for(key), "should still have a cache" + assert cache.local_cache, "should still have a cache" body.each { } - assert LocalCacheRegistry.cache_for(key), "should still have a cache" + assert cache.local_cache, "should still have a cache" body.close - assert_nil LocalCacheRegistry.cache_for(key) + assert_nil cache.local_cache end def test_local_cache_cleared_and_response_should_be_present_on_invalid_parameters_error - key = "super awesome key" - assert_nil LocalCacheRegistry.cache_for key - middleware = Middleware.new("<3", key).new(->(env) { - assert LocalCacheRegistry.cache_for(key), "should have a cache" + cache = Cache.new + assert_nil cache.local_cache + middleware = Middleware.new("<3", cache).new(->(env) { + assert cache.local_cache, "should have a cache" raise Rack::Utils::InvalidParameterError }) response = middleware.call({}) assert response, "response should exist" - assert_nil LocalCacheRegistry.cache_for(key) + assert_nil cache.local_cache end def test_local_cache_cleared_on_exception - key = "super awesome key" - assert_nil LocalCacheRegistry.cache_for key - middleware = Middleware.new("<3", key).new(->(env) { - assert LocalCacheRegistry.cache_for(key), "should have a cache" + cache = Cache.new + assert_nil cache.local_cache + middleware = Middleware.new("<3", cache).new(->(env) { + assert cache.local_cache, "should have a cache" raise }) assert_raises(RuntimeError) { middleware.call({}) } - assert_nil LocalCacheRegistry.cache_for(key) + assert_nil cache.local_cache end def test_local_cache_cleared_on_throw - key = "super awesome key" - assert_nil LocalCacheRegistry.cache_for key - middleware = Middleware.new("<3", key).new(->(env) { - assert LocalCacheRegistry.cache_for(key), "should have a cache" + cache = Cache.new + assert_nil cache.local_cache + middleware = Middleware.new("<3", cache).new(->(env) { + assert cache.local_cache, "should have a cache" throw :warden }) assert_throws(:warden) { middleware.call({}) } - assert_nil LocalCacheRegistry.cache_for(key) + assert_nil cache.local_cache end end end From cb9b414fb106c51d9e3891025c3b8c449712dc0e Mon Sep 17 00:00:00 2001 From: Gannon McGibbon Date: Fri, 22 Aug 2025 15:46:12 -0500 Subject: [PATCH 0497/1075] Make cache of local cache middleware updatable If the cache client at `Rails.cache` of a booted application changes, the corresponding mounted middleware needs to update in order for request-local caches to be setup properly. Otherwise, redundant cache operations will erroneously hit the datastore. --- activesupport/CHANGELOG.md | 8 ++++++++ .../cache/strategy/local_cache_middleware.rb | 3 ++- .../test/cache/local_cache_middleware_test.rb | 12 ++++++++++++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/activesupport/CHANGELOG.md b/activesupport/CHANGELOG.md index e02e5b049d8d2..c869b2d491357 100644 --- a/activesupport/CHANGELOG.md +++ b/activesupport/CHANGELOG.md @@ -1,3 +1,11 @@ +* Make the cache of `ActiveSupport::Cache::Strategy::LocalCache::Middleware` updatable. + + If the cache client at `Rails.cache` of a booted application changes, the corresponding + mounted middleware needs to update in order for request-local caches to be setup properly. + Otherwise, redundant cache operations will erroneously hit the datastore. + + *Gannon McGibbon* + * Add `assert_events_reported` test helper for `ActiveSupport::EventReporter`. This new assertion allows testing multiple events in a single block, regardless of order: diff --git a/activesupport/lib/active_support/cache/strategy/local_cache_middleware.rb b/activesupport/lib/active_support/cache/strategy/local_cache_middleware.rb index ef471f2d880dc..d5476731eb3de 100644 --- a/activesupport/lib/active_support/cache/strategy/local_cache_middleware.rb +++ b/activesupport/lib/active_support/cache/strategy/local_cache_middleware.rb @@ -11,7 +11,8 @@ module LocalCache # This class wraps up local storage for middlewares. Only the middleware method should # construct them. class Middleware # :nodoc: - attr_reader :name, :cache + attr_reader :name + attr_accessor :cache def initialize(name, cache) @name = name diff --git a/activesupport/test/cache/local_cache_middleware_test.rb b/activesupport/test/cache/local_cache_middleware_test.rb index db1cae0db8f1d..7ed6c4874c004 100644 --- a/activesupport/test/cache/local_cache_middleware_test.rb +++ b/activesupport/test/cache/local_cache_middleware_test.rb @@ -60,6 +60,18 @@ def test_local_cache_cleared_on_throw assert_throws(:warden) { middleware.call({}) } assert_nil cache.local_cache end + + def test_local_cache_middlewre_can_reassign_cache + cache = Cache.new + new_cache = Cache.new + middleware = Middleware.new("<3", cache).new(->(env) { + assert cache.local_cache, "should have a cache" + throw :warden + }) + middleware.cache = new_cache + + assert_same(new_cache, middleware.cache) + end end end end From 2cffe069f26c95c14ba1136b45e504696b8c4e97 Mon Sep 17 00:00:00 2001 From: Ryuta Kamizono Date: Mon, 25 Aug 2025 00:27:59 +0900 Subject: [PATCH 0498/1075] Add `ActiveRecord::CheckViolation` error class for check constraint violations This follows the same pattern as other constraint violation error classes like `RecordNotUnique` for unique constraint violations, `InvalidForeignKey` for foreign key constraint violations, and `ExclusionViolation` for exclusion constraint violations. --- activerecord/CHANGELOG.md | 4 +++ .../abstract_mysql_adapter.rb | 4 +++ .../connection_adapters/postgresql_adapter.rb | 3 ++ .../connection_adapters/sqlite3_adapter.rb | 2 ++ activerecord/lib/active_record/errors.rb | 4 +++ .../cases/migration/check_constraint_test.rb | 34 +++++++++++++++++++ 6 files changed, 51 insertions(+) diff --git a/activerecord/CHANGELOG.md b/activerecord/CHANGELOG.md index d6cbd66f111ff..c1a0240d86e8b 100644 --- a/activerecord/CHANGELOG.md +++ b/activerecord/CHANGELOG.md @@ -1,3 +1,7 @@ +* Add `ActiveRecord::CheckViolation` error class for check constraint violations. + + *Ryuta Kamizono* + * Add `ActiveRecord::ExclusionViolation` error class for exclusion constraint violations. When an exclusion constraint is violated in PostgreSQL, the error will now be raised 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 f1ae3b32711ba..843b04589a078 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb @@ -838,6 +838,8 @@ def warning_ignored?(warning) CR_SERVER_LOST = 2013 ER_QUERY_TIMEOUT = 3024 ER_FK_INCOMPATIBLE_COLUMNS = 3780 + ER_CHECK_CONSTRAINT_VIOLATED = 3819 + ER_CONSTRAINT_FAILED = 4025 ER_CLIENT_INTERACTION_TIMEOUT = 4031 def translate_exception(exception, message:, sql:, binds:) @@ -870,6 +872,8 @@ def translate_exception(exception, message:, sql:, binds:) RangeError.new(message, sql: sql, binds: binds, connection_pool: @pool) when ER_NOT_NULL_VIOLATION, ER_DO_NOT_HAVE_DEFAULT NotNullViolation.new(message, sql: sql, binds: binds, connection_pool: @pool) + when ER_CHECK_CONSTRAINT_VIOLATED, ER_CONSTRAINT_FAILED + CheckViolation.new(message, sql: sql, binds: binds, connection_pool: @pool) when ER_LOCK_DEADLOCK Deadlocked.new(message, sql: sql, binds: binds, connection_pool: @pool) when ER_LOCK_WAIT_TIMEOUT diff --git a/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb b/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb index b9c320b3cc7f4..ff78e840360ba 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb @@ -791,6 +791,7 @@ def has_default_function?(default_value, default) NOT_NULL_VIOLATION = "23502" FOREIGN_KEY_VIOLATION = "23503" UNIQUE_VIOLATION = "23505" + CHECK_VIOLATION = "23514" EXCLUSION_VIOLATION = "23P01" SERIALIZATION_FAILURE = "40001" DEADLOCK_DETECTED = "40P01" @@ -823,6 +824,8 @@ def translate_exception(exception, message:, sql:, binds:) RecordNotUnique.new(message, sql: sql, binds: binds, connection_pool: @pool) when FOREIGN_KEY_VIOLATION InvalidForeignKey.new(message, sql: sql, binds: binds, connection_pool: @pool) + when CHECK_VIOLATION + CheckViolation.new(message, sql: sql, binds: binds, connection_pool: @pool) when EXCLUSION_VIOLATION ExclusionViolation.new(message, sql: sql, binds: binds, connection_pool: @pool) when VALUE_LIMIT_VIOLATION diff --git a/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb b/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb index f9908b864fd12..97c39ed546c77 100644 --- a/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb @@ -730,6 +730,8 @@ def translate_exception(exception, message:, sql:, binds:) NotNullViolation.new(message, sql: sql, binds: binds, connection_pool: @pool) elsif exception.message.match?(/FOREIGN KEY constraint failed/i) InvalidForeignKey.new(message, sql: sql, binds: binds, connection_pool: @pool) + elsif exception.message.match?(/CHECK constraint failed: .*/i) + CheckViolation.new(message, sql: sql, binds: binds, connection_pool: @pool) elsif exception.message.match?(/called on a closed database/i) ConnectionNotEstablished.new(exception, connection_pool: @pool) elsif exception.is_a?(::SQLite3::BusyException) diff --git a/activerecord/lib/active_record/errors.rb b/activerecord/lib/active_record/errors.rb index ab175ef6d05b4..3fa42a48dd298 100644 --- a/activerecord/lib/active_record/errors.rb +++ b/activerecord/lib/active_record/errors.rb @@ -292,6 +292,10 @@ def set_query(sql, binds) class NotNullViolation < StatementInvalid end + # Raised when a record cannot be inserted or updated because it would violate a check constraint. + class CheckViolation < StatementInvalid + end + # Raised when a record cannot be inserted or updated because it would violate an exclusion constraint. class ExclusionViolation < StatementInvalid end diff --git a/activerecord/test/cases/migration/check_constraint_test.rb b/activerecord/test/cases/migration/check_constraint_test.rb index eb79644315404..e0bcc09cc427d 100644 --- a/activerecord/test/cases/migration/check_constraint_test.rb +++ b/activerecord/test/cases/migration/check_constraint_test.rb @@ -313,6 +313,40 @@ def test_remove_constraint_from_change_table_with_options end end end + + class CheckConstraintViolationTest < ActiveRecord::TestCase + self.use_transactional_tests = false + + class Trade < ActiveRecord::Base + end + + setup do + @connection = ActiveRecord::Base.lease_connection + @connection.create_table "trades", force: true do |t| + t.integer :price + t.integer :quantity + t.check_constraint "price > 0", name: "price_check" + end + end + + teardown do + @connection.drop_table "trades", if_exists: true + end + + def test_check_constraint_violation_on_insert + assert_raises(ActiveRecord::CheckViolation) do + Trade.create(price: -10, quantity: 5) + end + end + + def test_check_constraint_violation_on_update + trade = Trade.create(price: 100, quantity: 5) + + assert_raises(ActiveRecord::CheckViolation) do + trade.update(price: -10) + end + end + end else module ActiveRecord class Migration From 5cc95ee31053cbc69a1b6dcb80180b69623c7f22 Mon Sep 17 00:00:00 2001 From: Adrianna Chang Date: Thu, 28 Aug 2025 12:40:28 -0400 Subject: [PATCH 0499/1075] Enable debug mode for events in local environments --- activesupport/lib/active_support/event_reporter.rb | 10 +++++++--- activesupport/test/event_reporter_test.rb | 5 +++++ railties/lib/rails/application/bootstrap.rb | 1 + 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/activesupport/lib/active_support/event_reporter.rb b/activesupport/lib/active_support/event_reporter.rb index 3ea0043e20b27..4bf90cd86f9b5 100644 --- a/activesupport/lib/active_support/event_reporter.rb +++ b/activesupport/lib/active_support/event_reporter.rb @@ -265,15 +265,18 @@ class EventReporter # event emission, or when unexpected arguments are passed to +notify+. attr_writer :raise_on_error + attr_writer :debug_mode # :nodoc: + class << self attr_accessor :context_store # :nodoc: end self.context_store = EventContext - def initialize(*subscribers, raise_on_error: false, tags: nil) + def initialize(*subscribers, raise_on_error: false) @subscribers = [] subscribers.each { |subscriber| subscribe(subscriber) } + @debug_mode = false @raise_on_error = raise_on_error end @@ -400,9 +403,10 @@ def with_debug Fiber[:event_reporter_debug_mode] = prior end - # Check if debug mode is currently enabled. + # Check if debug mode is currently enabled. Debug mode is enabled on the reporter + # via +with_debug+, and in local environments. def debug_mode? - Fiber[:event_reporter_debug_mode] + @debug_mode || Fiber[:event_reporter_debug_mode] end # Report an event only when in debug mode. For example: diff --git a/activesupport/test/event_reporter_test.rb b/activesupport/test/event_reporter_test.rb index 17347ec31c438..93da28f2480ca 100644 --- a/activesupport/test/event_reporter_test.rb +++ b/activesupport/test/event_reporter_test.rb @@ -248,6 +248,11 @@ def emit(event) assert_not_predicate @reporter, :debug_mode? end + test "#debug_mode? returns true when debug_mode=true is set" do + @reporter.debug_mode = true + assert_predicate @reporter, :debug_mode? + end + test "#with_debug works with nested calls" do @reporter.with_debug do assert_predicate @reporter, :debug_mode? diff --git a/railties/lib/rails/application/bootstrap.rb b/railties/lib/rails/application/bootstrap.rb index 78ca9950968bb..3be48e4a1a7ce 100644 --- a/railties/lib/rails/application/bootstrap.rb +++ b/railties/lib/rails/application/bootstrap.rb @@ -73,6 +73,7 @@ module Bootstrap initializer :initialize_event_reporter, group: :all do Rails.event.raise_on_error = config.consider_all_requests_local + Rails.event.debug_mode = config.consider_all_requests_local end # Initialize cache early in the stack so railties can make use of it. From ec032b10ff701c5f0425d2cffa0f0b69b2bda6b1 Mon Sep 17 00:00:00 2001 From: Hartley McGuire Date: Thu, 28 Aug 2025 14:02:28 -0400 Subject: [PATCH 0500/1075] Simplify retrieving columns_hash in InsertAll While implementing something similar in my application I noticed that models already have a cached columns_hash available from ModelSchema without having to go through the schema_cache/connection. --- activerecord/lib/active_record/insert_all.rb | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/activerecord/lib/active_record/insert_all.rb b/activerecord/lib/active_record/insert_all.rb index c603e112f1643..a2b281fc94f33 100644 --- a/activerecord/lib/active_record/insert_all.rb +++ b/activerecord/lib/active_record/insert_all.rb @@ -236,13 +236,13 @@ def into end def values_list - types = extract_types_from_columns_on(model.table_name, keys: keys_including_timestamps) + types = extract_types_for(keys_including_timestamps) values_list = insert_all.map_key_with_value do |key, value| if Arel::Nodes::SqlLiteral === value value elsif primary_keys.include?(key) && value.nil? - connection.default_insert_value(column_from_key(key)) + connection.default_insert_value(model.columns_hash[key]) else ActiveModel::Type::SerializeCastValue.serialize(type = types[key], type.cast(value)) end @@ -308,8 +308,8 @@ def columns_list format_columns(insert_all.keys_including_timestamps) end - def extract_types_from_columns_on(table_name, keys:) - columns = @model.schema_cache.columns_hash(table_name) + def extract_types_for(keys) + columns = @model.columns_hash unknown_column = (keys - columns.keys).first raise UnknownAttributeError.new(model.new, unknown_column) if unknown_column @@ -328,10 +328,6 @@ def quote_columns(columns) def quote_column(column) connection.quote_column_name(column) end - - def column_from_key(key) - model.schema_cache.columns_hash(model.table_name)[key] - end end end end From eec1ed10355b3c7b710accf3789e01a295c7a015 Mon Sep 17 00:00:00 2001 From: Adrianna Chang Date: Thu, 28 Aug 2025 15:22:33 -0400 Subject: [PATCH 0501/1075] Event Reporter should return nil from #notify --- activesupport/lib/active_support/event_reporter.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/activesupport/lib/active_support/event_reporter.rb b/activesupport/lib/active_support/event_reporter.rb index 4bf90cd86f9b5..1bb2c4bcb8b0e 100644 --- a/activesupport/lib/active_support/event_reporter.rb +++ b/activesupport/lib/active_support/event_reporter.rb @@ -387,6 +387,8 @@ def notify(name_or_object, payload = nil, caller_depth: 1, **kwargs) ActiveSupport.error_reporter.report(subscriber_error, handled: true) end end + + nil end # Temporarily enables debug mode for the duration of the block. From 5d8d50e78413e544dec611a3f9ce999c832cd522 Mon Sep 17 00:00:00 2001 From: zzak Date: Fri, 29 Aug 2025 12:06:16 +0900 Subject: [PATCH 0502/1075] Restore latest sidekiq gem for tests First, this failure was fixed in Sidekiq 8.0.5: https://github.com/sidekiq/sidekiq/blob/main/Changes.md#805 ``` $ AJ_INTEGRATION_TESTS=1 \ AJ_ADAPTER=sidekiq \ bin/test test/integration/queuing_test.rb \ -n test_should_interrupt_jobs Using sidekiq INFO 2025-08-29T01:39:43.257Z pid=1413670 tid=ucva: Sidekiq 8.0.2 connecting to Redis with options {size: 10, pool_name: "internal", url: "redis://redis:6379/1"} Run options: -n test_should_interrupt_jobs --seed 56399 Failure: QueuingTest#test_should_interrupt_jobs [test/integration/queuing_test.rb:61]: Expected false to be truthy. bin/test test/integration/queuing_test.rb:50 ``` Second, this failure was originally introduced in Sidekiq 8.0.3. ``` $ bundle exec rake test:integration:sidekiq # ruby -w -I"lib:test" /home/zzak/.rbenv/versions/3.4.5/lib/ruby/gems/3.4.0/gems/rake-13.3.0/lib/rake/rake_test_loader.rb "test/integration/queuing_test.rb" Using sidekiq INFO 2025-08-29T01:41:28.604Z pid=1444128 tid=uxfc: Sidekiq 8.0.7 connecting to Redis with options {size: 10, pool_name: "internal", url: "redis://redis:6379/1"} Run options: --seed 54098 Failure: QueuingTest#test_should_supply_a_wrapped_class_name_to_Sidekiq [test/integration/queuing_test.rb:38]: --- expected +++ actual @@ -1 +1 @@ -"ActiveJob::QueueAdapters::SidekiqAdapter::JobWrapper" +"Sidekiq::ActiveJob::Wrapper" bin/rails test activejob/test/integration/queuing_test.rb:34 ``` This is because when the Sidekiq gem is loaded with Rails, it replaces the native Active Job adapter with it's own. When enqueuing jobs, the `"class"` attribute is set to `Sidekiq::ActiveJob::Wrapper`. https://github.com/sidekiq/sidekiq/blob/205df02327a2402f4c844df07324d8ac3da80ffb/lib/active_job/queue_adapters/sidekiq_adapter.rb#L79 That is the source of both of these problems. We should allow the gem to control the adapter as it will be deprecated and removed internally from Rails in the future. This change is intended to be backported to other stable branches to ensure the Sidekiq adapter remains supported. --- Gemfile | 2 +- Gemfile.lock | 4 ++-- activejob/test/integration/queuing_test.rb | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Gemfile b/Gemfile index bff7b75a4feca..e2c24fef654f3 100644 --- a/Gemfile +++ b/Gemfile @@ -102,7 +102,7 @@ gem "useragent", require: false group :job do gem "resque", require: false gem "resque-scheduler", require: false - gem "sidekiq", "!= 8.0.3", require: false + gem "sidekiq", require: false gem "sucker_punch", require: false gem "queue_classic", ">= 4.0.0", require: false, platforms: :ruby gem "sneakers", require: false diff --git a/Gemfile.lock b/Gemfile.lock index 7313d8b149f82..d8a670c296caf 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -592,7 +592,7 @@ GEM serverengine (2.0.7) sigdump (~> 0.2.2) set (1.1.2) - sidekiq (8.0.2) + sidekiq (8.0.7) connection_pool (>= 2.5.0) json (>= 2.9.0) logger (>= 1.6.2) @@ -809,7 +809,7 @@ DEPENDENCIES rubyzip (~> 2.0) sdoc! selenium-webdriver (>= 4.20.0) - sidekiq (!= 8.0.3) + sidekiq sneakers solid_cable solid_cache diff --git a/activejob/test/integration/queuing_test.rb b/activejob/test/integration/queuing_test.rb index 3c2106006b03d..81751e665f786 100644 --- a/activejob/test/integration/queuing_test.rb +++ b/activejob/test/integration/queuing_test.rb @@ -35,7 +35,7 @@ class QueuingTest < ActiveSupport::TestCase Sidekiq::Testing.fake! do ::HelloJob.perform_later hash = ActiveJob::QueueAdapters::SidekiqAdapter::JobWrapper.jobs.first - assert_equal "ActiveJob::QueueAdapters::SidekiqAdapter::JobWrapper", hash["class"] + assert_equal "Sidekiq::ActiveJob::Wrapper", hash["class"] assert_equal "HelloJob", hash["wrapped"] end end From 31cf6d55b071cb7aab1634aa9a0e43488119dbea Mon Sep 17 00:00:00 2001 From: Aidan Haran Date: Fri, 29 Aug 2025 11:24:56 +0100 Subject: [PATCH 0503/1075] Fix for unsubscribing from event reporter using class --- activesupport/lib/active_support/event_reporter.rb | 2 +- activesupport/test/event_reporter_test.rb | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/activesupport/lib/active_support/event_reporter.rb b/activesupport/lib/active_support/event_reporter.rb index 1bb2c4bcb8b0e..98fe9f48a3f1b 100644 --- a/activesupport/lib/active_support/event_reporter.rb +++ b/activesupport/lib/active_support/event_reporter.rb @@ -314,7 +314,7 @@ def subscribe(subscriber, &filter) # # or # Rails.event.unsubscribe(MyEventSubscriber) def unsubscribe(subscriber) - @subscribers.delete_if { |s| s[:subscriber] === subscriber } + @subscribers.delete_if { |s| subscriber === s[:subscriber] } end # Reports an event to all registered subscribers. An event name and payload can be provided: diff --git a/activesupport/test/event_reporter_test.rb b/activesupport/test/event_reporter_test.rb index 93da28f2480ca..dc96bae2f200f 100644 --- a/activesupport/test/event_reporter_test.rb +++ b/activesupport/test/event_reporter_test.rb @@ -72,7 +72,9 @@ def emit(event) end test "#unsubscribe" do + first_subscriber = @subscriber second_subscriber = EventSubscriber.new + @reporter.subscribe(second_subscriber) @reporter.notify(:test_event, key: "value") @@ -86,7 +88,12 @@ def emit(event) @reporter.notify(:another_event, key: "value") end + assert event_matcher(name: "another_event", payload: { key: "value" }).call(first_subscriber.events.last) + + @reporter.unsubscribe(EventSubscriber) @reporter.notify(:last_event, key: "value") + + assert_empty first_subscriber.events.select(&event_matcher(name: "last_event", payload: { key: "value" })) assert_empty second_subscriber.events.select(&event_matcher(name: "last_event", payload: { key: "value" })) end From 21e324d3e712d241e44fa27d490dafff74283848 Mon Sep 17 00:00:00 2001 From: Nick Schwaderer Date: Wed, 27 Aug 2025 14:56:05 +0100 Subject: [PATCH 0504/1075] Makes a parallel_worker_id value available when running tests Right now the only opportunity to access the parallel worker id is to grab it in ``` parallelize_setup do |worker_id| ``` Where it must be assigned to a config by hand. This PR makes a helper `parallel_worker_id` available throughout. It is `nil` if not in parallel. Co-authored-by: Eileen --- activesupport/CHANGELOG.md | 5 +++ activesupport/lib/active_support/test_case.rb | 20 +++++++++++ .../testing/parallelization/worker.rb | 2 ++ activesupport/test/parallelization_test.rb | 36 +++++++++++++++++++ 4 files changed, 63 insertions(+) create mode 100644 activesupport/test/parallelization_test.rb diff --git a/activesupport/CHANGELOG.md b/activesupport/CHANGELOG.md index c869b2d491357..3381a356fcf67 100644 --- a/activesupport/CHANGELOG.md +++ b/activesupport/CHANGELOG.md @@ -1,3 +1,8 @@ +* Create `parallel_worker_id` helper for running parallel tests. This allows users to + know which worker they are currently running in. + + *Nick Schwaderer* + * Make the cache of `ActiveSupport::Cache::Strategy::LocalCache::Middleware` updatable. If the cache client at `Rails.cache` of a booted application changes, the corresponding diff --git a/activesupport/lib/active_support/test_case.rb b/activesupport/lib/active_support/test_case.rb index d6e26d3b584d2..58f59d8d97292 100644 --- a/activesupport/lib/active_support/test_case.rb +++ b/activesupport/lib/active_support/test_case.rb @@ -23,7 +23,22 @@ module ActiveSupport class TestCase < ::Minitest::Test Assertion = Minitest::Assertion + # Class variable to store the parallel worker ID + @@parallel_worker_id = nil + class << self + # Returns the current parallel worker ID if tests are running in parallel, + # nil otherwise. + # + # ActiveSupport::TestCase.parallel_worker_id # => 2 + def parallel_worker_id + @@parallel_worker_id + end + + def parallel_worker_id=(value) # :nodoc: + @@parallel_worker_id = value + end + # Sets the order in which test cases are run. # # ActiveSupport::TestCase.test_order = :random # => :random @@ -174,6 +189,11 @@ def parallelize_teardown(&block) alias_method :method_name, :name + # Returns the current parallel worker ID if tests are running in parallel + def parallel_worker_id + self.class.parallel_worker_id + end + include ActiveSupport::Testing::TaggedLogging prepend ActiveSupport::Testing::SetupAndTeardown prepend ActiveSupport::Testing::TestsWithoutAssertions diff --git a/activesupport/lib/active_support/testing/parallelization/worker.rb b/activesupport/lib/active_support/testing/parallelization/worker.rb index 393355a25fe15..fb9f0533a8954 100644 --- a/activesupport/lib/active_support/testing/parallelization/worker.rb +++ b/activesupport/lib/active_support/testing/parallelization/worker.rb @@ -78,6 +78,8 @@ def safe_record(reporter, result) end def after_fork + ActiveSupport::TestCase.parallel_worker_id = @number + Parallelization.after_fork_hooks.each do |cb| cb.call(@number) end diff --git a/activesupport/test/parallelization_test.rb b/activesupport/test/parallelization_test.rb new file mode 100644 index 0000000000000..1c067975011b5 --- /dev/null +++ b/activesupport/test/parallelization_test.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require_relative "abstract_unit" + +class ParallelizationTest < ActiveSupport::TestCase + def setup + @original_worker_id = ActiveSupport::TestCase.parallel_worker_id + end + + def teardown + ActiveSupport::TestCase.parallel_worker_id = @original_worker_id + end + + test "parallel_worker_id is accessible as an attribute and method" do + ActiveSupport::TestCase.parallel_worker_id = nil + assert_nil ActiveSupport::TestCase.parallel_worker_id + assert_nil parallel_worker_id + end + + test "parallel_worker_id is set and accessible from class and instance" do + ActiveSupport::TestCase.parallel_worker_id = 3 + + assert_equal 3, ActiveSupport::TestCase.parallel_worker_id + assert_equal 3, parallel_worker_id + end + + test "parallel_worker_id persists across test subclasses" do + ActiveSupport::TestCase.parallel_worker_id = 5 + + subclass = Class.new(ActiveSupport::TestCase) + assert_equal 5, subclass.parallel_worker_id + + instance = subclass.new("test") + assert_equal 5, instance.parallel_worker_id + end +end From 4b716529d20061e47b6b9ad40caad77e28723ccf Mon Sep 17 00:00:00 2001 From: Adrianna Chang Date: Fri, 29 Aug 2025 11:43:41 -0400 Subject: [PATCH 0505/1075] The context_store setter is defined on the class, not the instance --- activesupport/lib/active_support/railtie.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/activesupport/lib/active_support/railtie.rb b/activesupport/lib/active_support/railtie.rb index 4acf2766e4a84..e4fa731c32f4c 100644 --- a/activesupport/lib/active_support/railtie.rb +++ b/activesupport/lib/active_support/railtie.rb @@ -41,7 +41,7 @@ class Railtie < Rails::Railtie # :nodoc: initializer "active_support.set_event_reporter_context_store" do |app| config.after_initialize do if klass = app.config.active_support.event_reporter_context_store - ActiveSupport.event_reporter.context_store = klass + ActiveSupport::EventReporter.context_store = klass end end end From b53ed590832d3d01215d58aacc539580d2661201 Mon Sep 17 00:00:00 2001 From: Yasuo Honda Date: Sat, 30 Aug 2025 15:50:11 +0900 Subject: [PATCH 0506/1075] Fix changelogs linter offense This commit addresses the following offense: https://buildkite.com/rails/rails/builds/121173#0198f70c-54f6-4def-b062-2848e5587d16/1310 ``` $ tools/railspect changelogs . ..........E.. Offenses: activesupport/CHANGELOG.md:5 Trailing whitespace detected. ^^^^ 13 changelogs inspected, 1 offense detected $ ``` --- activesupport/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/activesupport/CHANGELOG.md b/activesupport/CHANGELOG.md index 3381a356fcf67..ad6388e47ab8d 100644 --- a/activesupport/CHANGELOG.md +++ b/activesupport/CHANGELOG.md @@ -2,7 +2,7 @@ know which worker they are currently running in. *Nick Schwaderer* - + * Make the cache of `ActiveSupport::Cache::Strategy::LocalCache::Middleware` updatable. If the cache client at `Rails.cache` of a booted application changes, the corresponding From 3fb706eb17e972507e101113df16df0a12ad3c9f Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sat, 30 Aug 2025 09:04:21 +0200 Subject: [PATCH 0507/1075] Add markdown mime type and renderer (#55511) * Add markdown mime type and renderer * Spell it out instead * Have aliases * Test it * Don't need the options, makes it clumsy to implement * Fix tests * Add changelog entry --- actionpack/CHANGELOG.md | 23 +++++++++++++++++++ .../lib/action_controller/metal/renderers.rb | 5 ++++ .../lib/action_dispatch/http/mime_types.rb | 1 + actionpack/test/controller/renderers_test.rb | 20 ++++++++++++++-- actionpack/test/dispatch/mime_type_test.rb | 6 ++--- 5 files changed, 50 insertions(+), 5 deletions(-) diff --git a/actionpack/CHANGELOG.md b/actionpack/CHANGELOG.md index 83963324dec9c..809dc35cd73d0 100644 --- a/actionpack/CHANGELOG.md +++ b/actionpack/CHANGELOG.md @@ -1,3 +1,26 @@ +* Add .md/.markdown as Markdown extensions and add a default `markdown:` renderer: + + ```ruby + class Page + def to_markdown + body + end + end + + class PagesController < ActionController::Base + def show + @page = Page.find(params[:id]) + + respond_to do |format| + format.html + format.md { render markdown: @page } + end + end + end + ``` + + *DHH* + * Add headers to engine routes inspection command *Petrik de Heus* diff --git a/actionpack/lib/action_controller/metal/renderers.rb b/actionpack/lib/action_controller/metal/renderers.rb index 26efdddb37385..2065a14e089c0 100644 --- a/actionpack/lib/action_controller/metal/renderers.rb +++ b/actionpack/lib/action_controller/metal/renderers.rb @@ -192,5 +192,10 @@ def _render_to_body_with_renderer(options) # :nodoc: self.content_type = :xml if media_type.nil? xml.respond_to?(:to_xml) ? xml.to_xml(options) : xml end + + add :markdown do |md, options| + self.content_type = :md if media_type.nil? + md.respond_to?(:to_markdown) ? md.to_markdown : md + end end end diff --git a/actionpack/lib/action_dispatch/http/mime_types.rb b/actionpack/lib/action_dispatch/http/mime_types.rb index 426154fe57dea..56e00af88d4ec 100644 --- a/actionpack/lib/action_dispatch/http/mime_types.rb +++ b/actionpack/lib/action_dispatch/http/mime_types.rb @@ -13,6 +13,7 @@ Mime::Type.register "text/csv", :csv Mime::Type.register "text/vcard", :vcf Mime::Type.register "text/vtt", :vtt, %w(vtt) +Mime::Type.register "text/markdown", :md, [], %w(md markdown) Mime::Type.register "image/png", :png, [], %w(png) Mime::Type.register "image/jpeg", :jpeg, [], %w(jpg jpeg jpe pjpeg) diff --git a/actionpack/test/controller/renderers_test.rb b/actionpack/test/controller/renderers_test.rb index 96cce664a4c97..6b520f7aae13d 100644 --- a/actionpack/test/controller/renderers_test.rb +++ b/actionpack/test/controller/renderers_test.rb @@ -11,6 +11,7 @@ def to_xml(options) "<#{options[:root]}/>" end end + class JsonRenderable def as_json(options = {}) hash = { a: :b, c: :d, e: :f } @@ -22,11 +23,19 @@ def to_json(options = {}) super except: [:c, :e] end end + class CsvRenderable def to_csv "c,s,v" end end + + class MarkdownRenderable + def to_markdown + "# This is markdown" + end + end + class TestController < ActionController::Base def render_simon_says render simon: "foo" @@ -38,8 +47,9 @@ def respond_to_mime render json: JsonRenderable.new end type.js { render json: "JS", callback: "alert" } - type.csv { render csv: CsvRenderable.new } - type.xml { render xml: XmlRenderable.new } + type.csv { render csv: CsvRenderable.new } + type.xml { render xml: XmlRenderable.new } + type.md { render markdown: MarkdownRenderable.new } type.html { render body: "HTML" } type.rss { render body: "RSS" } type.all { render body: "Nothing" } @@ -88,4 +98,10 @@ def test_adding_csv_rendering_via_renderers_add ensure ActionController::Renderers.remove :csv end + + test "rendering markdown" do + get :respond_to_mime, format: "md" + assert_equal Mime[:markdown], @response.media_type + assert_equal "# This is markdown", @response.body + end end diff --git a/actionpack/test/dispatch/mime_type_test.rb b/actionpack/test/dispatch/mime_type_test.rb index 32245bda328e6..fe2d7da5ad40f 100644 --- a/actionpack/test/dispatch/mime_type_test.rb +++ b/actionpack/test/dispatch/mime_type_test.rb @@ -30,21 +30,21 @@ class MimeTypeTest < ActiveSupport::TestCase test "parse text with trailing star at the beginning" do accept = "text/*, text/html, application/json, multipart/form-data" - expect = [Mime[:html], Mime[:text], Mime[:js], Mime[:css], Mime[:ics], Mime[:csv], Mime[:vcf], Mime[:vtt], Mime[:xml], Mime[:yaml], Mime[:json], Mime[:multipart_form]] + expect = [Mime[:html], Mime[:text], Mime[:js], Mime[:css], Mime[:ics], Mime[:csv], Mime[:vcf], Mime[:vtt], Mime[:markdown], Mime[:xml], Mime[:yaml], Mime[:json], Mime[:multipart_form]] parsed = Mime::Type.parse(accept) assert_equal expect.map(&:to_s), parsed.map(&:to_s) end test "parse text with trailing star in the end" do accept = "text/html, application/json, multipart/form-data, text/*" - expect = [Mime[:html], Mime[:json], Mime[:multipart_form], Mime[:text], Mime[:js], Mime[:css], Mime[:ics], Mime[:csv], Mime[:vcf], Mime[:vtt], Mime[:xml], Mime[:yaml]] + expect = [Mime[:html], Mime[:json], Mime[:multipart_form], Mime[:text], Mime[:js], Mime[:css], Mime[:ics], Mime[:csv], Mime[:vcf], Mime[:vtt], Mime[:markdown], Mime[:xml], Mime[:yaml]] parsed = Mime::Type.parse(accept) assert_equal expect.map(&:to_s), parsed.map(&:to_s) end test "parse text with trailing star" do accept = "text/*" - expect = [Mime[:html], Mime[:text], Mime[:js], Mime[:css], Mime[:ics], Mime[:csv], Mime[:vcf], Mime[:vtt], Mime[:xml], Mime[:yaml], Mime[:json]] + expect = [Mime[:html], Mime[:text], Mime[:js], Mime[:css], Mime[:ics], Mime[:csv], Mime[:vcf], Mime[:vtt], Mime[:xml], Mime[:yaml], Mime[:json], Mime[:markdown]] parsed = Mime::Type.parse(accept) assert_equal expect.map(&:to_s).sort!, parsed.map(&:to_s).sort! end From 579e91a2ae9ab7ddfc253dfe33365115bb097af1 Mon Sep 17 00:00:00 2001 From: Nikhil <154459496+alphaplayerofdooms@users.noreply.github.com> Date: Sat, 30 Aug 2025 13:05:18 +0530 Subject: [PATCH 0508/1075] =?UTF-8?q?Fix=20typo=20in=20Rails=208.0=20relea?= =?UTF-8?q?se=20notes:=20'registery=5Fdirectory'=20=E2=86=92=20'register?= =?UTF-8?q?=5Fdirectory'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- guides/source/8_0_release_notes.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/guides/source/8_0_release_notes.md b/guides/source/8_0_release_notes.md index ba09850f8372e..961d4ad9b1d6a 100644 --- a/guides/source/8_0_release_notes.md +++ b/guides/source/8_0_release_notes.md @@ -90,7 +90,7 @@ Please refer to the [Changelog][railties] for detailed changes. * Deprecate requiring `"rails/console/methods"`. * Deprecate modifying `STATS_DIRECTORIES` in favor of - `Rails::CodeStatistics.registery_directory`. + `Rails::CodeStatistics.register_directory`. * Deprecate `bin/rake stats` in favor of `bin/rails stats`. From b6023059689263a95cbf22b2c2c7ca3d3d29c3b0 Mon Sep 17 00:00:00 2001 From: Nick Schwaderer Date: Fri, 29 Aug 2025 11:30:13 +0100 Subject: [PATCH 0509/1075] Allow getter and setter for `options[:namespace]` in `ActiveSupport::Cache::Store` In development or after initialization a user may want to inspect the current cache namespace. They may also wish to change it. This PR adds this functionality. Note: custom namespaces passed in as options during the cache calls will override any previously set namespace value during that call. Why: To make parallel tests more resilient, we have found that it is better to reset the cache namespace between tests instead of clearing the cache. This functionality can be used as follows: ```ruby def randomize_namespace @store = Rails.cache.store namespace = @store.namespace if namespace namespace = "#{SecureRandom.hex(6)}r:" + namespace.split("r:")[-1] else namespace = SecureRandom.hex(6) end @store.namespace = namespace end ```` --- activesupport/CHANGELOG.md | 7 +++++++ activesupport/lib/active_support/cache.rb | 11 +++++++++++ activesupport/test/cache/cache_key_test.rb | 10 ++++++++++ 3 files changed, 28 insertions(+) diff --git a/activesupport/CHANGELOG.md b/activesupport/CHANGELOG.md index ad6388e47ab8d..4f7a41032ca67 100644 --- a/activesupport/CHANGELOG.md +++ b/activesupport/CHANGELOG.md @@ -1,3 +1,10 @@ +* Add `ActiveSupport::Cache::Store#namespace=` and `#namespace`. + + Can be used as an alternative to `Store#clear` in some situations such as parallel + testing. + + *Nick Schwaderer* + * Create `parallel_worker_id` helper for running parallel tests. This allows users to know which worker they are currently running in. diff --git a/activesupport/lib/active_support/cache.rb b/activesupport/lib/active_support/cache.rb index cf260af174a89..195a038c7eb7c 100644 --- a/activesupport/lib/active_support/cache.rb +++ b/activesupport/lib/active_support/cache.rb @@ -796,6 +796,17 @@ def clear(options = nil) raise NotImplementedError.new("#{self.class.name} does not support clear") end + # Get the current namespace + def namespace + @options[:namespace] + end + + # Set the current namespace. Note, this will be ignored if custom + # options are passed to cache wills with a namespace key. + def namespace=(namespace) + @options[:namespace] = namespace + end + private def default_serializer case Cache.format_version diff --git a/activesupport/test/cache/cache_key_test.rb b/activesupport/test/cache/cache_key_test.rb index e66e55d2d7f67..90e0b48c1d770 100644 --- a/activesupport/test/cache/cache_key_test.rb +++ b/activesupport/test/cache/cache_key_test.rb @@ -78,6 +78,16 @@ def test_expand_cache_key_of_array_like_object assert_equal "foo/bar/baz", ActiveSupport::Cache.expand_cache_key(%w{foo bar baz}.to_enum) end + def test_set_and_get_namespace + cache = ActiveSupport::Cache::MemoryStore.new + assert_nil cache.namespace + cache.namespace = "test" + assert_equal "test", cache.namespace + + cache.namespace = "test2" + assert_equal "test2", cache.namespace + end + private def with_env(kv) old_values = {} From b6c472accd371de359f9ddd13c1f24e14e0d3019 Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Sat, 30 Aug 2025 17:43:44 +0200 Subject: [PATCH 0510/1075] Optimize ActiveJob::Serializers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix: https://github.com/rails/rails/pull/55579 The main optimization is to index serializers by their `klass`, so that in most cases we can do a `O(1)` lookup rather than a `O(n)` one. If the hash lookup misses, we fallback to the older, slower method. This requires making `#klass` public, but it's easily handled with a deprecation warning at register time. Benchmark: ``` ruby 3.5.0dev (2025-08-27T10:41:07Z master 5257e1298c) +YJIT +PRISM [arm64-darwin24] Warming up -------------------------------------- opt-aj-serialize-index 16.219k i/100ms Calculating ------------------------------------- opt-aj-serialize-index 162.589k (± 1.6%) i/s (6.15 μs/i) - 827.169k in 5.088801s Comparison: opt-aj-serialize-index: 162589.5 i/s main: 51687.7 i/s - 3.15x slower ``` ```ruby require "bundler/inline" gemfile do gem "benchmark-ips" gem "rails", path: "." end require "benchmark/ips" require "active_job/railtie" class TestApp < Rails::Application config.load_defaults Rails::VERSION::STRING.to_f config.eager_load = false config.secret_key_base = "secret_key_base" config.active_job.queue_adapter = :test config.logger = Logger.new(nil) end Rails.application.initialize! module Mod end class CustomClass end class CustomClass2 end class CustomClass3 end class CustomClassSerializer < ActiveJob::Serializers::ObjectSerializer def serialize(argument) super({ "foo" => "bar" }) end def klass CustomClass end end class CustomClass2Serializer < ActiveJob::Serializers::ObjectSerializer def serialize(argument) super({ "foo" => "bar2" }) end def klass CustomClass2 end end class CustomClass3Serializer < ActiveJob::Serializers::ObjectSerializer def serialize(argument) super({ "foo" => "bar3" }) end def klass CustomClass3 end end if File.basename(ENV.fetch("BUNDLE_GEMFILE")) == "Gemfile.next" ActiveJob::Serializers.add_class_based_serializer(CustomClass, CustomClassSerializer.instance) ActiveJob::Serializers.add_class_based_serializer(CustomClass2, CustomClass2Serializer.instance) ActiveJob::Serializers.add_class_based_serializer(CustomClass3, CustomClass3Serializer.instance) else ActiveJob::Serializers.add_serializers(CustomClassSerializer.instance) ActiveJob::Serializers.add_serializers(CustomClass2Serializer.instance) ActiveJob::Serializers.add_serializers(CustomClass3Serializer.instance) end ARGS = [ 1..2, DateTime.now, Time.now, Date.today, Mod, BigDecimal("1.12345"), :foo, ActiveSupport::Duration.build(31556952), Time.zone.now, CustomClass.new, CustomClass2.new, CustomClass3.new ].freeze BRANCH = `git rev-parse --abbrev-ref HEAD`.strip Benchmark.ips do |x| x.report(BRANCH) do ActiveJob::Arguments.serialize(ARGS) end x.save!("/tmp/bench-aj-serialize") x.compare! end ``` Co-Authored-By: Cameron Dutro --- activejob/CHANGELOG.md | 7 +++ activejob/lib/active_job/serializers.rb | 51 +++++++++++++------ .../serializers/big_decimal_serializer.rb | 7 ++- .../active_job/serializers/date_serializer.rb | 7 ++- .../serializers/date_time_serializer.rb | 7 ++- .../serializers/duration_serializer.rb | 7 ++- .../serializers/module_serializer.rb | 7 ++- .../serializers/object_serializer.rb | 21 ++++---- .../serializers/range_serializer.rb | 7 ++- .../serializers/symbol_serializer.rb | 7 ++- .../active_job/serializers/time_serializer.rb | 7 ++- .../serializers/time_with_zone_serializer.rb | 7 ++- .../test/cases/argument_serialization_test.rb | 9 ++-- activejob/test/cases/serializers_test.rb | 11 ++-- .../test/application/configuration_test.rb | 8 ++- 15 files changed, 94 insertions(+), 76 deletions(-) diff --git a/activejob/CHANGELOG.md b/activejob/CHANGELOG.md index a93fdcdb51396..16cbbf41bd9ed 100644 --- a/activejob/CHANGELOG.md +++ b/activejob/CHANGELOG.md @@ -1,3 +1,10 @@ +* `ActiveJob::Serializers::ObjectSerializers#klass` method is now public. + + Custom Active Job serializers must have a public `#klass` method too. + The returned class will be index allowing for faster serialization. + + *Jean Boussier* + * Allow jobs to the interrupted and resumed with Continuations A job can use Continuations by including the `ActiveJob::Continuable` diff --git a/activejob/lib/active_job/serializers.rb b/activejob/lib/active_job/serializers.rb index f0bb79838d965..9e6a88a5dd258 100644 --- a/activejob/lib/active_job/serializers.rb +++ b/activejob/lib/active_job/serializers.rb @@ -20,15 +20,15 @@ module Serializers # :nodoc: autoload :RangeSerializer autoload :BigDecimalSerializer - mattr_accessor :_additional_serializers - self._additional_serializers = Set.new + @serializers = Set.new + @serializers_index = {} class << self # Returns serialized representative of the passed object. # Will look up through all known serializers. # Raises ActiveJob::SerializationError if it can't find a proper serializer. def serialize(argument) - serializer = serializers.detect { |s| s.serialize?(argument) } + serializer = @serializers_index[argument.class] || serializers.find { |s| s.serialize?(argument) } raise SerializationError.new("Unsupported argument type: #{argument.class.name}") unless serializer serializer.serialize(argument) end @@ -47,24 +47,45 @@ def deserialize(argument) end # Returns list of known serializers. - def serializers - self._additional_serializers + attr_reader :serializers + + def serializers=(serializers) + @serializers = serializers + index_serializers end # Adds new serializers to a list of known serializers. def add_serializers(*new_serializers) - self._additional_serializers += new_serializers.flatten + new_serializers = new_serializers.flatten + @serializers += new_serializers + index_serializers + @serializers end + + private + def index_serializers + @serializers_index.clear + serializers.each do |s| + if s.respond_to?(:klass) + @serializers_index[s.klass] = s + elsif s.respond_to?(:klass, true) + ActiveJob.deprecator.warn(<<~MSG.squish) + #{s.klass.name}#klass method should be public. + MSG + @serializers_index[s.send(:klass)] = s + end + end + end end - add_serializers SymbolSerializer, - DurationSerializer, - DateTimeSerializer, - DateSerializer, - TimeWithZoneSerializer, - TimeSerializer, - ModuleSerializer, - RangeSerializer, - BigDecimalSerializer + add_serializers SymbolSerializer.instance, + DurationSerializer.instance, + DateTimeSerializer.instance, + DateSerializer.instance, + TimeWithZoneSerializer.instance, + TimeSerializer.instance, + ModuleSerializer.instance, + RangeSerializer.instance, + BigDecimalSerializer.instance end end diff --git a/activejob/lib/active_job/serializers/big_decimal_serializer.rb b/activejob/lib/active_job/serializers/big_decimal_serializer.rb index 1d03f872af325..8b74399f8a8e7 100644 --- a/activejob/lib/active_job/serializers/big_decimal_serializer.rb +++ b/activejob/lib/active_job/serializers/big_decimal_serializer.rb @@ -13,10 +13,9 @@ def deserialize(hash) BigDecimal(hash["value"]) end - private - def klass - BigDecimal - end + def klass + BigDecimal + end end end end diff --git a/activejob/lib/active_job/serializers/date_serializer.rb b/activejob/lib/active_job/serializers/date_serializer.rb index 3ec587bc54ede..79ab02b812c7a 100644 --- a/activejob/lib/active_job/serializers/date_serializer.rb +++ b/activejob/lib/active_job/serializers/date_serializer.rb @@ -11,10 +11,9 @@ def deserialize(hash) Date.iso8601(hash["value"]) end - private - def klass - Date - end + def klass + Date + end end end end diff --git a/activejob/lib/active_job/serializers/date_time_serializer.rb b/activejob/lib/active_job/serializers/date_time_serializer.rb index 958a3ea8ac595..a620b9a6462d3 100644 --- a/activejob/lib/active_job/serializers/date_time_serializer.rb +++ b/activejob/lib/active_job/serializers/date_time_serializer.rb @@ -7,10 +7,9 @@ def deserialize(hash) DateTime.iso8601(hash["value"]) end - private - def klass - DateTime - end + def klass + DateTime + end end end end diff --git a/activejob/lib/active_job/serializers/duration_serializer.rb b/activejob/lib/active_job/serializers/duration_serializer.rb index ab0696adaa879..576b5a16648db 100644 --- a/activejob/lib/active_job/serializers/duration_serializer.rb +++ b/activejob/lib/active_job/serializers/duration_serializer.rb @@ -16,10 +16,9 @@ def deserialize(hash) klass.new(value, parts.to_h) end - private - def klass - ActiveSupport::Duration - end + def klass + ActiveSupport::Duration + end end end end diff --git a/activejob/lib/active_job/serializers/module_serializer.rb b/activejob/lib/active_job/serializers/module_serializer.rb index 081a1d9c55eca..834c3efbc6143 100644 --- a/activejob/lib/active_job/serializers/module_serializer.rb +++ b/activejob/lib/active_job/serializers/module_serializer.rb @@ -12,10 +12,9 @@ def deserialize(hash) hash["value"].constantize end - private - def klass - Module - end + def klass + Module + end end end end diff --git a/activejob/lib/active_job/serializers/object_serializer.rb b/activejob/lib/active_job/serializers/object_serializer.rb index 2b39915d57ee2..c5e4e3120b05d 100644 --- a/activejob/lib/active_job/serializers/object_serializer.rb +++ b/activejob/lib/active_job/serializers/object_serializer.rb @@ -17,11 +17,9 @@ module Serializers # Money.new(hash["amount"], hash["currency"]) # end # - # private - # - # def klass - # Money - # end + # def klass + # Money + # end # end class ObjectSerializer include Singleton @@ -41,15 +39,14 @@ def serialize(hash) end # Deserializes an argument from a JSON primitive type. - def deserialize(json) - raise NotImplementedError + def deserialize(hash) + raise NotImplementedError, "#{self.class.name} should implement a public #deserialize(hash) method" end - private - # The class of the object that will be serialized. - def klass # :doc: - raise NotImplementedError - end + # The class of the object that will be serialized. + def klass + raise NotImplementedError, "#{self.class.name} should implement a public #klass method" + end end end end diff --git a/activejob/lib/active_job/serializers/range_serializer.rb b/activejob/lib/active_job/serializers/range_serializer.rb index 3d21c251fecee..b9cfeacc8a599 100644 --- a/activejob/lib/active_job/serializers/range_serializer.rb +++ b/activejob/lib/active_job/serializers/range_serializer.rb @@ -14,10 +14,9 @@ def deserialize(hash) klass.new(*Arguments.deserialize(hash.values_at(*KEYS))) end - private - def klass - ::Range - end + def klass + ::Range + end end end end diff --git a/activejob/lib/active_job/serializers/symbol_serializer.rb b/activejob/lib/active_job/serializers/symbol_serializer.rb index 7db7f3bbf837a..9e4bea27780f1 100644 --- a/activejob/lib/active_job/serializers/symbol_serializer.rb +++ b/activejob/lib/active_job/serializers/symbol_serializer.rb @@ -11,10 +11,9 @@ def deserialize(argument) argument["value"].to_sym end - private - def klass - Symbol - end + def klass + Symbol + end end end end diff --git a/activejob/lib/active_job/serializers/time_serializer.rb b/activejob/lib/active_job/serializers/time_serializer.rb index e4f55a4838e5b..ef951595145b3 100644 --- a/activejob/lib/active_job/serializers/time_serializer.rb +++ b/activejob/lib/active_job/serializers/time_serializer.rb @@ -7,10 +7,9 @@ def deserialize(hash) Time.iso8601(hash["value"]) end - private - def klass - Time - end + def klass + Time + end end end end diff --git a/activejob/lib/active_job/serializers/time_with_zone_serializer.rb b/activejob/lib/active_job/serializers/time_with_zone_serializer.rb index 3b68f0f3f5ae2..6e5e409112461 100644 --- a/activejob/lib/active_job/serializers/time_with_zone_serializer.rb +++ b/activejob/lib/active_job/serializers/time_with_zone_serializer.rb @@ -16,10 +16,9 @@ def deserialize(hash) Time.iso8601(hash["value"]).in_time_zone(hash["time_zone"] || Time.zone) end - private - def klass - ActiveSupport::TimeWithZone - end + def klass + ActiveSupport::TimeWithZone + end end end end diff --git a/activejob/test/cases/argument_serialization_test.rb b/activejob/test/cases/argument_serialization_test.rb index 08183b0a30112..3b8334024a37e 100644 --- a/activejob/test/cases/argument_serialization_test.rb +++ b/activejob/test/cases/argument_serialization_test.rb @@ -35,10 +35,9 @@ def deserialize(hash) MyString.new(hash["value"]) end - private - def klass - MyString - end + def klass + MyString + end end class StringWithoutSerializer < String @@ -133,7 +132,7 @@ class StringWithoutSerializer < String assert_instance_of MyString, deserialized assert_equal my_string, deserialized ensure - ActiveJob::Serializers._additional_serializers = original_serializers + ActiveJob::Serializers.serializers = original_serializers end test "serialize a String subclass object without a serializer" do diff --git a/activejob/test/cases/serializers_test.rb b/activejob/test/cases/serializers_test.rb index 56db57aa81494..5c1615a90f291 100644 --- a/activejob/test/cases/serializers_test.rb +++ b/activejob/test/cases/serializers_test.rb @@ -25,10 +25,9 @@ def deserialize(hash) DummyValueObject.new(hash["value"]) end - private - def klass - DummyValueObject - end + def klass + DummyValueObject + end end setup do @@ -37,7 +36,7 @@ def klass end teardown do - ActiveJob::Serializers._additional_serializers = @original_serializers + ActiveJob::Serializers.serializers = @original_serializers end test "can't serialize unknown object" do @@ -85,7 +84,7 @@ def klass test "adds new serializer" do ActiveJob::Serializers.add_serializers DummySerializer - assert ActiveJob::Serializers.serializers.include?(DummySerializer) + assert ActiveJob::Serializers.serializers.include?(DummySerializer.instance) end test "can't add serializer with the same key twice" do diff --git a/railties/test/application/configuration_test.rb b/railties/test/application/configuration_test.rb index de3eecbc7ec1f..eecaa6eef7fe8 100644 --- a/railties/test/application/configuration_test.rb +++ b/railties/test/application/configuration_test.rb @@ -3238,7 +3238,11 @@ class Post < ActiveRecord::Base end test "custom serializers should be able to set via config.active_job.custom_serializers in an initializer" do - class ::DummySerializer < ActiveJob::Serializers::ObjectSerializer; end + class ::DummySerializer < ActiveJob::Serializers::ObjectSerializer + def klass + nil + end + end app_file "config/initializers/custom_serializers.rb", <<-RUBY Rails.application.config.active_job.custom_serializers << DummySerializer @@ -3246,7 +3250,7 @@ class ::DummySerializer < ActiveJob::Serializers::ObjectSerializer; end app "development" - assert_includes ActiveJob::Serializers.serializers, DummySerializer + assert_includes ActiveJob::Serializers.serializers, DummySerializer.instance end test "config.active_job.verbose_enqueue_logs defaults to true in development" do From 1f8a0c06c163696618032d1001a0bdf9e2ac1ed1 Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Sat, 30 Aug 2025 18:02:18 +0200 Subject: [PATCH 0511/1075] Optimize Active Job ObjectSerializer#serialize MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Creating a small hash just to `merge!` into it is wasteful. We can directly mutate the received hash instead. Same benchmark as the parent commit: ``` ruby 3.5.0dev (2025-08-27T10:41:07Z master 5257e1298c) +YJIT +PRISM [arm64-darwin24] Warming up -------------------------------------- opt-aj-serialize-index 18.899k i/100ms Calculating ------------------------------------- opt-aj-serialize-index 191.770k (± 1.0%) i/s (5.21 μs/i) - 963.849k in 5.026580s Comparison: previous-commit: 160807.1 i/s opt-aj-serialize-index: 191769.8 i/s - 1.19x faster ``` --- activejob/lib/active_job/serializers/object_serializer.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/activejob/lib/active_job/serializers/object_serializer.rb b/activejob/lib/active_job/serializers/object_serializer.rb index c5e4e3120b05d..5ca798383e596 100644 --- a/activejob/lib/active_job/serializers/object_serializer.rb +++ b/activejob/lib/active_job/serializers/object_serializer.rb @@ -35,7 +35,8 @@ def serialize?(argument) # Serializes an argument to a JSON primitive type. def serialize(hash) - { Arguments::OBJECT_SERIALIZER_KEY => self.class.name }.merge!(hash) + hash[Arguments::OBJECT_SERIALIZER_KEY] = self.class.name + hash end # Deserializes an argument from a JSON primitive type. From 4b27a2a498655e96f6f4b5e66b5cbec83191cd98 Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Sat, 30 Aug 2025 18:09:16 +0200 Subject: [PATCH 0512/1075] Optimize TimeWithZone#xmlschema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ref: https://github.com/ruby/ruby/pull/11510 In Ruby 3.4 I optimized `Time#xmlschema`, so that it's now much faster than the `strftime` `TimeWithZone` was using. ``` ruby 3.5.0dev (2025-08-27T10:41:07Z master 5257e1298c) +YJIT +PRISM [arm64-darwin24] Warming up -------------------------------------- opt-aj-serialize-index 1.177M i/100ms Calculating ------------------------------------- opt-aj-serialize-index 13.961M (± 0.5%) i/s (71.63 ns/i) - 70.594M in 5.056554s Comparison: previous-commit: 1993282.1 i/s opt-aj-serialize-index: 13961260.3 i/s - 7.00x faster ``` Bench: ```ruby require "bundler/inline" gemfile do gem "benchmark-ips" gem "rails", path: "." end require "benchmark/ips" require "active_job/railtie" class TestApp < Rails::Application config.load_defaults Rails::VERSION::STRING.to_f config.eager_load = false config.secret_key_base = "secret_key_base" config.active_job.queue_adapter = :test config.logger = Logger.new(nil) end Rails.application.initialize! utc_time = Time.zone.now BRANCH = `git rev-parse --abbrev-ref HEAD`.strip Benchmark.ips do |x| x.report(BRANCH) do utc_time.xmlschema end x.save!("/tmp/bench-twz") x.compare!(order: :baseline) end ``` Since serializing TimeWithZone is a significant part of the Active Job benchmark, it has an effect there too: ``` ruby 3.5.0dev (2025-08-27T10:41:07Z master 5257e1298c) +YJIT +PRISM [arm64-darwin24] Warming up -------------------------------------- opt-aj-serialize-index 20.870k i/100ms Calculating ------------------------------------- opt-aj-serialize-index 213.253k (± 1.9%) i/s (4.69 μs/i) - 1.085M in 5.090838s Comparison: previous-commit: 188008.3 i/s opt-aj-serialize-index: 213252.7 i/s - 1.13x faster ``` --- .../lib/active_support/time_with_zone.rb | 24 +++++++++++++++---- .../test/core_ext/time_with_zone_test.rb | 6 ----- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/activesupport/lib/active_support/time_with_zone.rb b/activesupport/lib/active_support/time_with_zone.rb index eb25fd46487c3..1b301dc85a3c7 100644 --- a/activesupport/lib/active_support/time_with_zone.rb +++ b/activesupport/lib/active_support/time_with_zone.rb @@ -49,9 +49,15 @@ class TimeWithZone attr_reader :time_zone def initialize(utc_time, time_zone, local_time = nil, period = nil) - @utc = utc_time ? transfer_time_values_to_utc_constructor(utc_time) : nil @time_zone, @time = time_zone, local_time - @period = @utc ? period : get_period_and_ensure_valid_local_time(period) + if utc_time + @utc = transfer_time_values_to_utc_constructor(utc_time) + @period = period + else + @utc = nil + @period = get_period_and_ensure_valid_local_time(period) + end + @is_utc = zone == "UTC" || zone == "UCT" end # Returns a Time instance that represents the time in +time_zone+. @@ -103,7 +109,7 @@ def dst? # Time.zone = 'Eastern Time (US & Canada)' # => 'Eastern Time (US & Canada)' # Time.zone.now.utc? # => false def utc? - zone == "UTC" || zone == "UCT" + @is_utc end alias_method :gmt?, :utc? @@ -146,7 +152,13 @@ def inspect # # Time.zone.now.xmlschema # => "2014-12-04T11:02:37-05:00" def xmlschema(fraction_digits = 0) - "#{time.strftime(PRECISIONS[fraction_digits.to_i])}#{formatted_offset(true, 'Z')}" + if @is_utc + utc.iso8601(fraction_digits || 0) + else + str = time.iso8601(fraction_digits || 0) + str[-1] = formatted_offset(true, "Z") + str + end end alias_method :iso8601, :xmlschema alias_method :rfc3339, :xmlschema @@ -560,7 +572,9 @@ def method_missing(...) SECONDS_PER_DAY = 86400 def incorporate_utc_offset(time, offset) - if time.kind_of?(Date) + if offset.zero? + time + elsif time.kind_of?(Date) time + Rational(offset, SECONDS_PER_DAY) else time + offset diff --git a/activesupport/test/core_ext/time_with_zone_test.rb b/activesupport/test/core_ext/time_with_zone_test.rb index 24f32fe54ba39..d413b6aab8926 100644 --- a/activesupport/test/core_ext/time_with_zone_test.rb +++ b/activesupport/test/core_ext/time_with_zone_test.rb @@ -736,12 +736,6 @@ def test_nsec_returns_sec_fraction_when_datetime_is_wrapped assert_equal 500000000, twz.nsec end - def test_utc_to_local_conversion_saves_period_in_instance_variable - assert_nil @twz.instance_variable_get("@period") - @twz.time - assert_kind_of TZInfo::TimezonePeriod, @twz.instance_variable_get("@period") - end - def test_instance_created_with_local_time_returns_correct_utc_time twz = ActiveSupport::TimeWithZone.new(nil, @time_zone, Time.utc(1999, 12, 31, 19)) assert_equal Time.utc(2000), twz.utc From 70f04a4b6d2b4dd2d85d5a1818fc676858210058 Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Sat, 30 Aug 2025 18:19:00 +0200 Subject: [PATCH 0513/1075] Inline Active Job SymbolSerializer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Symbols are frequent enough that it's worth handling them in the main dispatch loop. ``` ruby 3.5.0dev (2025-08-27T10:41:07Z master 5257e1298c) +YJIT +PRISM [arm64-darwin24] Warming up -------------------------------------- opt-aj-serialize-index 22.701k i/100ms Calculating ------------------------------------- opt-aj-serialize-index 229.469k (± 1.2%) i/s (4.36 μs/i) - 1.158M in 5.046113s Comparison: previous-commit: 213237.9 i/s opt-aj-serialize-index: 229468.9 i/s - 1.08x faster ``` --- activejob/lib/active_job/arguments.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/activejob/lib/active_job/arguments.rb b/activejob/lib/active_job/arguments.rb index 9094a7bceecb0..1ad5e1405cfd9 100644 --- a/activejob/lib/active_job/arguments.rb +++ b/activejob/lib/active_job/arguments.rb @@ -82,6 +82,8 @@ def serialize_argument(argument) argument end end + when Symbol + { OBJECT_SERIALIZER_KEY => "ActiveJob::Serializers::SymbolSerializer", "value" => argument.name } when GlobalID::Identification convert_to_global_id_hash(argument) when Array From 21fac8d8eeb147bb7ff4ec25b01e0007a04462a4 Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Sat, 30 Aug 2025 18:23:05 +0200 Subject: [PATCH 0514/1075] Optimize Active Job RangeSerializer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I don't expect it to be commonly used, but there was some very low hanging fruits and it's part of the active job benchmark. ``` ruby 3.5.0dev (2025-08-27T10:41:07Z master 5257e1298c) +YJIT +PRISM [arm64-darwin24] Warming up -------------------------------------- opt-aj-serialize-index 23.521k i/100ms Calculating ------------------------------------- opt-aj-serialize-index 238.931k (± 1.3%) i/s (4.19 μs/i) - 1.200M in 5.021480s Comparison: previous-commit: 229029.5 i/s opt-aj-serialize-index: 238931.1 i/s - 1.04x faster ``` --- activejob/lib/active_job/arguments.rb | 90 +++++++++---------- .../serializers/duration_serializer.rb | 4 +- .../serializers/range_serializer.rb | 11 +-- 3 files changed, 51 insertions(+), 54 deletions(-) diff --git a/activejob/lib/active_job/arguments.rb b/activejob/lib/active_job/arguments.rb index 1ad5e1405cfd9..7680b91322b14 100644 --- a/activejob/lib/active_job/arguments.rb +++ b/activejob/lib/active_job/arguments.rb @@ -31,8 +31,48 @@ module Arguments # serialized without mutation are returned as-is. Arrays/Hashes are # serialized element by element. All other types are serialized using # GlobalID. - def serialize(arguments) - arguments.map { |argument| serialize_argument(argument) } + def serialize(argument) + case argument + when nil, true, false, Integer, Float # Types that can hardly be subclassed + argument + when String + if argument.class == String + argument + else + begin + Serializers.serialize(argument) + rescue SerializationError + argument + end + end + when Symbol + { OBJECT_SERIALIZER_KEY => "ActiveJob::Serializers::SymbolSerializer", "value" => argument.name } + when GlobalID::Identification + convert_to_global_id_hash(argument) + when Array + argument.map { |arg| serialize(arg) } + when ActiveSupport::HashWithIndifferentAccess + serialize_indifferent_hash(argument) + when Hash + symbol_keys = argument.keys + symbol_keys.select! { |k| k.is_a?(Symbol) } + symbol_keys.map!(&:name) + + aj_hash_key = if Hash.ruby2_keywords_hash?(argument) + RUBY2_KEYWORDS_KEY + else + SYMBOL_KEYS_KEY + end + result = serialize_hash(argument) + result[aj_hash_key] = symbol_keys + result + else + if argument.respond_to?(:permitted?) && argument.respond_to?(:to_h) + serialize_indifferent_hash(argument.to_h) + else + Serializers.serialize(argument) + end + end end # Deserializes a set of arguments. Intrinsic types that can safely be @@ -68,50 +108,6 @@ def deserialize(arguments) private_constant :RESERVED_KEYS, :GLOBALID_KEY, :SYMBOL_KEYS_KEY, :RUBY2_KEYWORDS_KEY, :WITH_INDIFFERENT_ACCESS_KEY - def serialize_argument(argument) - case argument - when nil, true, false, Integer, Float # Types that can hardly be subclassed - argument - when String - if argument.class == String - argument - else - begin - Serializers.serialize(argument) - rescue SerializationError - argument - end - end - when Symbol - { OBJECT_SERIALIZER_KEY => "ActiveJob::Serializers::SymbolSerializer", "value" => argument.name } - when GlobalID::Identification - convert_to_global_id_hash(argument) - when Array - argument.map { |arg| serialize_argument(arg) } - when ActiveSupport::HashWithIndifferentAccess - serialize_indifferent_hash(argument) - when Hash - symbol_keys = argument.keys - symbol_keys.select! { |k| k.is_a?(Symbol) } - symbol_keys.map!(&:name) - - aj_hash_key = if Hash.ruby2_keywords_hash?(argument) - RUBY2_KEYWORDS_KEY - else - SYMBOL_KEYS_KEY - end - result = serialize_hash(argument) - result[aj_hash_key] = symbol_keys - result - else - if argument.respond_to?(:permitted?) && argument.respond_to?(:to_h) - serialize_indifferent_hash(argument.to_h) - else - Serializers.serialize(argument) - end - end - end - def deserialize_argument(argument) case argument when nil, true, false, String, Integer, Float @@ -145,7 +141,7 @@ def custom_serialized?(hash) def serialize_hash(argument) argument.each_with_object({}) do |(key, value), hash| - hash[serialize_hash_key(key)] = serialize_argument(value) + hash[serialize_hash_key(key)] = serialize(value) end end diff --git a/activejob/lib/active_job/serializers/duration_serializer.rb b/activejob/lib/active_job/serializers/duration_serializer.rb index 576b5a16648db..db7b18c277e2f 100644 --- a/activejob/lib/active_job/serializers/duration_serializer.rb +++ b/activejob/lib/active_job/serializers/duration_serializer.rb @@ -6,12 +6,12 @@ class DurationSerializer < ObjectSerializer # :nodoc: def serialize(duration) # Ideally duration.parts would be wrapped in an array before passing to Arguments.serialize, # but we continue passing the bare hash for backwards compatibility: - super("value" => duration.value, "parts" => Arguments.serialize(duration.parts)) + super("value" => duration.value, "parts" => Arguments.serialize(duration.parts.to_a)) end def deserialize(hash) value = hash["value"] - parts = Arguments.deserialize(hash["parts"]) + parts = Arguments.deserialize(hash["parts"].to_h) # `parts` is originally a hash, but will have been flattened to an array by Arguments.serialize klass.new(value, parts.to_h) end diff --git a/activejob/lib/active_job/serializers/range_serializer.rb b/activejob/lib/active_job/serializers/range_serializer.rb index b9cfeacc8a599..313cbc746d4df 100644 --- a/activejob/lib/active_job/serializers/range_serializer.rb +++ b/activejob/lib/active_job/serializers/range_serializer.rb @@ -3,15 +3,16 @@ module ActiveJob module Serializers class RangeSerializer < ObjectSerializer - KEYS = %w[begin end exclude_end].freeze - def serialize(range) - args = Arguments.serialize([range.begin, range.end, range.exclude_end?]) - super(KEYS.zip(args).to_h) + super( + "begin" => Arguments.serialize(range.begin), + "end" => Arguments.serialize(range.end), + "exclude_end" => range.exclude_end?, # Always boolean, no need to serialize + ) end def deserialize(hash) - klass.new(*Arguments.deserialize(hash.values_at(*KEYS))) + Range.new(*Arguments.deserialize([hash["begin"], hash["end"]]), hash["exclude_end"]) end def klass From f155f3b81db75572fb996368596549304b857d5b Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Sat, 30 Aug 2025 18:37:36 +0200 Subject: [PATCH 0515/1075] Extract Active Job AC::Parameters support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Right now AC::Parameters is supported by checking `respond_to?(:permitted?)`, but this feels like a cheaty way to not be coupled with Action Controller. But more importantly, it means we need to check `respond_to?` for a lot of types when there is a very small likelyhood to match. Instead we can refactor this into a proper Serializer and benefit from the `O(1)` lookup. ``` ruby 3.5.0dev (2025-08-27T10:41:07Z master 5257e1298c) +YJIT +PRISM [arm64-darwin24] Warming up -------------------------------------- opt-aj-serialize-index 25.177k i/100ms Calculating ------------------------------------- opt-aj-serialize-index 257.174k (± 2.1%) i/s (3.89 μs/i) - 1.309M in 5.093171s Comparison: previous-commit: 237324.4 i/s opt-aj-serialize-index: 257174.0 i/s - 1.08x faster ``` --- activejob/lib/active_job/arguments.rb | 6 +---- activejob/lib/active_job/railtie.rb | 8 ++++++ activejob/lib/active_job/serializers.rb | 9 +++++++ ...action_controller_parameters_serializer.rb | 25 +++++++++++++++++++ .../test/cases/argument_serialization_test.rb | 7 ++++++ 5 files changed, 50 insertions(+), 5 deletions(-) create mode 100644 activejob/lib/active_job/serializers/action_controller_parameters_serializer.rb diff --git a/activejob/lib/active_job/arguments.rb b/activejob/lib/active_job/arguments.rb index 7680b91322b14..ee4bb6308d5f2 100644 --- a/activejob/lib/active_job/arguments.rb +++ b/activejob/lib/active_job/arguments.rb @@ -67,11 +67,7 @@ def serialize(argument) result[aj_hash_key] = symbol_keys result else - if argument.respond_to?(:permitted?) && argument.respond_to?(:to_h) - serialize_indifferent_hash(argument.to_h) - else - Serializers.serialize(argument) - end + Serializers.serialize(argument) end end diff --git a/activejob/lib/active_job/railtie.rb b/activejob/lib/active_job/railtie.rb index d8624ba7b9822..eda0c9d898f27 100644 --- a/activejob/lib/active_job/railtie.rb +++ b/activejob/lib/active_job/railtie.rb @@ -52,6 +52,14 @@ class Railtie < Rails::Railtie # :nodoc: end end + initializer "active_job.action_controller_parameters" do |app| + ActiveSupport.on_load(:active_job) do + ActiveSupport.on_load(:action_controller) do + ActiveJob::Serializers.add_serializers ActiveJob::Serializers::ActionControllerParametersSerializer + end + end + end + initializer "active_job.set_configs" do |app| options = app.config.active_job options.queue_adapter ||= (Rails.env.test? ? :test : :async) diff --git a/activejob/lib/active_job/serializers.rb b/activejob/lib/active_job/serializers.rb index 9e6a88a5dd258..69277a492022e 100644 --- a/activejob/lib/active_job/serializers.rb +++ b/activejob/lib/active_job/serializers.rb @@ -19,6 +19,7 @@ module Serializers # :nodoc: autoload :ModuleSerializer autoload :RangeSerializer autoload :BigDecimalSerializer + autoload :ActionControllerParametersSerializer @serializers = Set.new @serializers_index = {} @@ -57,6 +58,14 @@ def serializers=(serializers) # Adds new serializers to a list of known serializers. def add_serializers(*new_serializers) new_serializers = new_serializers.flatten + new_serializers.map! do |s| + if s.is_a?(Class) && s < ObjectSerializer + s.instance + else + s + end + end + @serializers += new_serializers index_serializers @serializers diff --git a/activejob/lib/active_job/serializers/action_controller_parameters_serializer.rb b/activejob/lib/active_job/serializers/action_controller_parameters_serializer.rb new file mode 100644 index 0000000000000..9c208247931a9 --- /dev/null +++ b/activejob/lib/active_job/serializers/action_controller_parameters_serializer.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module ActiveJob + module Serializers + class ActionControllerParametersSerializer < ObjectSerializer + def serialize(argument) + Arguments.serialize(argument.to_h.with_indifferent_access) + end + + def deserialize(hash) + raise NotImplementedError # Serialized as a HashWithIndifferentAccess + end + + def serialize?(argument) + argument.respond_to?(:permitted?) && argument.respond_to?(:to_h) + end + + def klass + if defined?(ActionController::Parameters) + ActionController::Parameters + end + end + end + end +end diff --git a/activejob/test/cases/argument_serialization_test.rb b/activejob/test/cases/argument_serialization_test.rb index 3b8334024a37e..015285dcf68e3 100644 --- a/activejob/test/cases/argument_serialization_test.rb +++ b/activejob/test/cases/argument_serialization_test.rb @@ -45,6 +45,11 @@ class StringWithoutSerializer < String setup do @person = Person.find("5") + @original_serializers = ActiveJob::Serializers.serializers + end + + teardown do + ActiveJob::Serializers.serializers = @original_serializers end [ nil, 1, 1.0, 1_000_000_000_000_000_000_000, @@ -109,6 +114,8 @@ class StringWithoutSerializer < String end test "serialize a ActionController::Parameters" do + ActiveJob::Serializers.add_serializers ActiveJob::Serializers::ActionControllerParametersSerializer + parameters = Parameters.new(a: 1) assert_equal( From 10a94392102b8b5d525b2d67cbe31a78741a85fe Mon Sep 17 00:00:00 2001 From: Marc Rohloff Date: Mon, 25 Aug 2025 12:07:01 -0600 Subject: [PATCH 0516/1075] Clarifies action of `db:migate:reset` in release notes Clarify that this is a destructive action closes rails/rails#55499 Emphasize important part of the ext (from review comment) Co-authored-by: Matheus Richard --- guides/source/8_0_release_notes.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/guides/source/8_0_release_notes.md b/guides/source/8_0_release_notes.md index 961d4ad9b1d6a..6aadc50f6cd1e 100644 --- a/guides/source/8_0_release_notes.md +++ b/guides/source/8_0_release_notes.md @@ -186,7 +186,10 @@ Please refer to the [Changelog][active-record] for detailed changes. ### Notable changes * Running `db:migrate` on a fresh database now loads the schema before running - migrations. (The previous behavior is available as `db:migrate:reset`) + migrations. Subsequent calls will run pending migrations. + (If you need the previous behavior of running migrations from scratch instead of loading the + schema file, this can be done by running `db:migrate:reset` which + _will drop and recreate the database before running migrations_) Active Storage -------------- From f46b838b3f24751f4e129c0550c71def9d3dfb9b Mon Sep 17 00:00:00 2001 From: Hartley McGuire Date: Sat, 30 Aug 2025 18:43:35 -0400 Subject: [PATCH 0517/1075] Simplify limit validation, move to call time Previously, limit values were validated while building the Arel tree, which can make it difficult to find the source of an invalid limit. This commit moves the validation to `limit!` (where its actually set) so that developers can more easily find the source of an invalid limit. This also has the additional benefit of removing the need for a connection in `build_arel` (which will be removed in a followup commit). `sanitize_limit` currently ensures that limit values are either a SqlLiteral or coerceable to an Integer. However, limits [use bind params][1] and so end up quoted when compiled into queries: ```ruby Topic.limit(Arel.sql("1, 1")).to_sql => SELECT "topics".* FROM "topics" LIMIT '1, 1' ``` Since SqlLiterals are quoted, you can actually only use SqlLiterals that end up being a single value anyways (eg. `Arel.sql("1")`). Therefore, instead of just inlining sanitize_limit into `limit!` it can be replaced with a simple `Integer()` wrapper. [1]: 574f255629a45cd67babcfb9bb8e163e091a53b8 Co-authored-by: Shuyang --- activerecord/CHANGELOG.md | 4 ++++ .../abstract/database_statements.rb | 14 -------------- .../lib/active_record/relation/query_methods.rb | 3 ++- activerecord/test/cases/base_test.rb | 6 +++--- activerecord/test/cases/relation/mutation_test.rb | 7 ++++++- 5 files changed, 15 insertions(+), 19 deletions(-) diff --git a/activerecord/CHANGELOG.md b/activerecord/CHANGELOG.md index c1a0240d86e8b..c31dd2d0fcca1 100644 --- a/activerecord/CHANGELOG.md +++ b/activerecord/CHANGELOG.md @@ -1,3 +1,7 @@ +* Move `LIMIT` validation from query generation to when `limit()` is called. + + *Hartley McGuire*, *Shuyang* + * Add `ActiveRecord::CheckViolation` error class for check constraint violations. *Ryuta Kamizono* 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 712fc444a6fbd..030d3a68113e9 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb @@ -502,20 +502,6 @@ def empty_insert_statement_value(primary_key = nil) "DEFAULT VALUES" end - # Sanitizes the given LIMIT parameter in order to prevent SQL injection. - # - # The +limit+ may be anything that can evaluate to a string via #to_s. It - # should look like an integer, or an Arel SQL literal. - # - # Returns Integer and Arel::Nodes::SqlLiteral limits as is. - def sanitize_limit(limit) - if limit.is_a?(Integer) || limit.is_a?(Arel::Nodes::SqlLiteral) - limit - else - Integer(limit) - end - end - # Fixture value is quoted by Arel, however scalar values # are not quotable. In this case we want to convert # the column value to YAML. diff --git a/activerecord/lib/active_record/relation/query_methods.rb b/activerecord/lib/active_record/relation/query_methods.rb index 746e670faacf3..52b4caf9dc61b 100644 --- a/activerecord/lib/active_record/relation/query_methods.rb +++ b/activerecord/lib/active_record/relation/query_methods.rb @@ -1213,6 +1213,7 @@ def limit(value) end def limit!(value) # :nodoc: + value = Integer(value) unless value.nil? self.limit_value = value self end @@ -1757,7 +1758,7 @@ def build_arel(connection, aliases = nil) arel.where(where_clause.ast) unless where_clause.empty? arel.having(having_clause.ast) unless having_clause.empty? - arel.take(build_cast_value("LIMIT", connection.sanitize_limit(limit_value))) if limit_value + arel.take(build_cast_value("LIMIT", limit_value)) if limit_value arel.skip(build_cast_value("OFFSET", offset_value.to_i)) if offset_value arel.group(*arel_columns(group_values)) unless group_values.empty? diff --git a/activerecord/test/cases/base_test.rb b/activerecord/test/cases/base_test.rb index e2446098a6b3c..54ada82e29791 100644 --- a/activerecord/test/cases/base_test.rb +++ b/activerecord/test/cases/base_test.rb @@ -192,19 +192,19 @@ def test_limit_should_take_value_from_latest_limit def test_invalid_limit assert_raises(ArgumentError) do - Topic.limit("asdfadf").to_a + Topic.limit("asdfadf") end end def test_limit_should_sanitize_sql_injection_for_limit_without_commas assert_raises(ArgumentError) do - Topic.limit("1 select * from schema").to_a + Topic.limit("1 select * from schema") end end def test_limit_should_sanitize_sql_injection_for_limit_with_commas assert_raises(ArgumentError) do - Topic.limit("1, 7 procedure help()").to_a + Topic.limit("1, 7 procedure help()") end end diff --git a/activerecord/test/cases/relation/mutation_test.rb b/activerecord/test/cases/relation/mutation_test.rb index af9739ce696ee..e5fa88f5e1849 100644 --- a/activerecord/test/cases/relation/mutation_test.rb +++ b/activerecord/test/cases/relation/mutation_test.rb @@ -52,7 +52,12 @@ class RelationMutationTest < ActiveRecord::TestCase assert_equal [], relation.extending_values end - (Relation::SINGLE_VALUE_METHODS - [:lock, :reordering, :reverse_order, :create_with, :skip_query_cache, :strict_loading]).each do |method| + test "#limit!" do + assert relation.limit!(5).equal?(relation) + assert_equal 5, relation.limit_value + end + + (Relation::SINGLE_VALUE_METHODS - [:limit, :lock, :reordering, :reverse_order, :create_with, :skip_query_cache, :strict_loading]).each do |method| test "##{method}!" do assert relation.public_send("#{method}!", :foo).equal?(relation) assert_equal :foo, relation.public_send("#{method}_value") From fa46b3ef0b97aa5126e667828824e1d1d9cb83d1 Mon Sep 17 00:00:00 2001 From: Hartley McGuire Date: Sat, 30 Aug 2025 19:03:27 -0400 Subject: [PATCH 0518/1075] Remove unused connection arg from {build_,}arel Partially revert 6ff9e82e4774d494298d6bfca2ceebe92aec64a1 because the argument is no longer used. Since `aliases` is the only argument again, I changed it back to be positional (which is what it was before the connection argument was added in mentioned commit). --- .../associations/join_dependency/join_association.rb | 2 +- activerecord/lib/active_record/relation.rb | 6 +++--- .../lib/active_record/relation/query_methods.rb | 10 +++------- 3 files changed, 7 insertions(+), 11 deletions(-) diff --git a/activerecord/lib/active_record/associations/join_dependency/join_association.rb b/activerecord/lib/active_record/associations/join_dependency/join_association.rb index 7f9e075c9168c..9102b6ac97665 100644 --- a/activerecord/lib/active_record/associations/join_dependency/join_association.rb +++ b/activerecord/lib/active_record/associations/join_dependency/join_association.rb @@ -53,7 +53,7 @@ def join_constraints(foreign_table, foreign_klass, join_type, alias_tracker) end end - arel = scope.arel(aliases: alias_tracker.aliases) + arel = scope.arel(alias_tracker.aliases) nodes = arel.constraints.first if nodes.is_a?(Arel::Nodes::And) diff --git a/activerecord/lib/active_record/relation.rb b/activerecord/lib/active_record/relation.rb index 6c44573e4464f..bf4e9a5821eaf 100644 --- a/activerecord/lib/active_record/relation.rb +++ b/activerecord/lib/active_record/relation.rb @@ -625,7 +625,7 @@ def update_all(updates) end model.with_connection do |c| - arel = eager_loading? ? apply_join_dependency.arel : arel(c) + arel = eager_loading? ? apply_join_dependency.arel : arel() arel.source.left = table key = if model.composite_primary_key? @@ -1040,7 +1040,7 @@ def delete_all end model.with_connection do |c| - arel = eager_loading? ? apply_join_dependency.arel : arel(c) + arel = eager_loading? ? apply_join_dependency.arel : arel() arel.source.left = table key = if model.composite_primary_key? @@ -1233,7 +1233,7 @@ def to_sql end else model.with_connection do |conn| - conn.unprepared_statement { conn.to_sql(arel(conn)) } + conn.unprepared_statement { conn.to_sql(arel) } end end end diff --git a/activerecord/lib/active_record/relation/query_methods.rb b/activerecord/lib/active_record/relation/query_methods.rb index 52b4caf9dc61b..c1bfb3970e9c8 100644 --- a/activerecord/lib/active_record/relation/query_methods.rb +++ b/activerecord/lib/active_record/relation/query_methods.rb @@ -1592,12 +1592,8 @@ def excluding!(records) # :nodoc: end # Returns the Arel object associated with the relation. - def arel(conn = nil, aliases: nil) # :nodoc: - @arel ||= if conn - build_arel(conn, aliases) - else - with_connection { |c| build_arel(c, aliases) } - end + def arel(aliases = nil) # :nodoc: + @arel ||= build_arel(aliases) end def construct_join_dependency(associations, join_type) # :nodoc: @@ -1751,7 +1747,7 @@ def assert_modifiable! raise UnmodifiableRelation if @loaded || @arel end - def build_arel(connection, aliases = nil) + def build_arel(aliases) arel = Arel::SelectManager.new(table) build_joins(arel.join_sources, aliases) From 59be27ef271560706c71a2224c9412ac0362cc9d Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Sun, 31 Aug 2025 17:02:02 +0200 Subject: [PATCH 0519/1075] Fix typo and improve grammar in Erubi Template Handler comment --- actionview/lib/action_view/template/handlers/erb/erubi.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/actionview/lib/action_view/template/handlers/erb/erubi.rb b/actionview/lib/action_view/template/handlers/erb/erubi.rb index b26a38ac1c849..174acfe07e79b 100644 --- a/actionview/lib/action_view/template/handlers/erb/erubi.rb +++ b/actionview/lib/action_view/template/handlers/erb/erubi.rb @@ -18,7 +18,7 @@ def initialize(input, properties = {}) properties[:preamble] ||= "" properties[:postamble] ||= "#{properties[:bufvar]}" - # Tell Eruby that whether template will be compiled with `frozen_string_literal: true` + # Tell Erubi whether the template will be compiled with `frozen_string_literal: true` properties[:freeze_template_literals] = !Template.frozen_string_literal properties[:escapefunc] = "" From e8b3164fc973a576315a57ba29918d9e2b97a8da Mon Sep 17 00:00:00 2001 From: Hartley McGuire Date: Mon, 1 Sep 2025 02:10:15 -0400 Subject: [PATCH 0520/1075] Remove TableMetadata -> Reflection delegation The delegation is unnecessary since the reflection instance is already available where needed in the predicate builder. Removing it simplifies TableMetadata (as well as the classes it was previously passed to only to delegate to a reflection), and prevents a TableMetadata allocation except when actually used (querying a through association). --- .../relation/predicate_builder.rb | 12 ++++++---- .../association_query_value.rb | 18 +++++++-------- .../polymorphic_array_value.rb | 14 +++++------ .../lib/active_record/table_metadata.rb | 23 ++++--------------- 4 files changed, 27 insertions(+), 40 deletions(-) diff --git a/activerecord/lib/active_record/relation/predicate_builder.rb b/activerecord/lib/active_record/relation/predicate_builder.rb index 6600ac9ebe3ff..3a595adb96f29 100644 --- a/activerecord/lib/active_record/relation/predicate_builder.rb +++ b/activerecord/lib/active_record/relation/predicate_builder.rb @@ -99,24 +99,26 @@ def expand_from_hash(attributes, &block) elsif value.is_a?(Hash) && !table.has_column?(key) table.associated_table(key, &block) .predicate_builder.expand_from_hash(value.stringify_keys) - elsif table.associated_with?(key) + elsif (associated_reflection = table.associated_with?(key)) # Find the foreign key when using queries such as: # Post.where(author: author) # # For polymorphic relationships, find the foreign key and type: # PriceEstimate.where(estimate_of: treasure) - associated_table = table.associated_table(key) - if associated_table.polymorphic_association? + + if associated_reflection.polymorphic? value = [value] unless value.is_a?(Array) klass = PolymorphicArrayValue - elsif associated_table.through_association? + elsif associated_reflection.through_reflection? + associated_table = table.associated_table(key) + next associated_table.predicate_builder.expand_from_hash( associated_table.primary_key => value ) end klass ||= AssociationQueryValue - queries = klass.new(associated_table, value).queries.map! do |query| + queries = klass.new(associated_reflection, value).queries.map! do |query| # If the query produced is identical to attributes don't go any deeper. # Prevents stack level too deep errors when association and foreign_key are identical. query == attributes ? self[key, value] : expand_from_hash(query) diff --git a/activerecord/lib/active_record/relation/predicate_builder/association_query_value.rb b/activerecord/lib/active_record/relation/predicate_builder/association_query_value.rb index af61dc34f3bf5..768c0cd4386d1 100644 --- a/activerecord/lib/active_record/relation/predicate_builder/association_query_value.rb +++ b/activerecord/lib/active_record/relation/predicate_builder/association_query_value.rb @@ -3,24 +3,24 @@ module ActiveRecord class PredicateBuilder class AssociationQueryValue # :nodoc: - def initialize(associated_table, value) - @associated_table = associated_table + def initialize(reflection, value) + @reflection = reflection @value = value end def queries - if associated_table.join_foreign_key.is_a?(Array) + if reflection.join_foreign_key.is_a?(Array) id_list = ids id_list = id_list.pluck(primary_key) if id_list.is_a?(Relation) - id_list.map { |ids_set| associated_table.join_foreign_key.zip(ids_set).to_h } + id_list.map { |ids_set| reflection.join_foreign_key.zip(ids_set).to_h } else - [ associated_table.join_foreign_key => ids ] + [ reflection.join_foreign_key => ids ] end end private - attr_reader :associated_table, :value + attr_reader :reflection, :value def ids case value @@ -37,15 +37,15 @@ def ids end def primary_key - associated_table.join_primary_key + reflection.join_primary_key end def primary_type - associated_table.join_primary_type + reflection.join_primary_type end def polymorphic_name - associated_table.polymorphic_name_association + reflection.polymorphic_name end def select_clause? 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 7179d6cc3be27..48f93d5811f3b 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 @@ -3,24 +3,24 @@ module ActiveRecord class PredicateBuilder class PolymorphicArrayValue # :nodoc: - def initialize(associated_table, values) - @associated_table = associated_table + def initialize(reflection, values) + @reflection = reflection @values = values end def queries - return [ associated_table.join_foreign_key => values ] if values.empty? + return [ reflection.join_foreign_key => values ] if values.empty? type_to_ids_mapping.map do |type, ids| query = {} - query[associated_table.join_foreign_type] = type if type - query[associated_table.join_foreign_key] = ids + query[reflection.join_foreign_type] = type if type + query[reflection.join_foreign_key] = ids query end end private - attr_reader :associated_table, :values + attr_reader :reflection, :values def type_to_ids_mapping default_hash = Hash.new { |hsh, key| hsh[key] = [] } @@ -30,7 +30,7 @@ def type_to_ids_mapping end def primary_key(value) - associated_table.join_primary_key(klass(value)) + reflection.join_primary_key(klass(value)) end def klass(value) diff --git a/activerecord/lib/active_record/table_metadata.rb b/activerecord/lib/active_record/table_metadata.rb index e8ec0bbc84c03..a70214b14b4ff 100644 --- a/activerecord/lib/active_record/table_metadata.rb +++ b/activerecord/lib/active_record/table_metadata.rb @@ -2,12 +2,9 @@ module ActiveRecord class TableMetadata # :nodoc: - delegate :join_primary_key, :join_primary_type, :join_foreign_key, :join_foreign_type, to: :reflection - - def initialize(klass, arel_table, reflection = nil) + def initialize(klass, arel_table) @klass = klass @arel_table = arel_table - @reflection = reflection end def primary_key @@ -42,26 +39,14 @@ def associated_table(table_name) if association_klass arel_table = association_klass.arel_table arel_table = arel_table.alias(table_name) if arel_table.name != table_name - TableMetadata.new(association_klass, arel_table, reflection) + TableMetadata.new(association_klass, arel_table) else type_caster = TypeCaster::Connection.new(klass, table_name) arel_table = Arel::Table.new(table_name, type_caster: type_caster) - TableMetadata.new(nil, arel_table, reflection) + TableMetadata.new(nil, arel_table) end end - def polymorphic_association? - reflection&.polymorphic? - end - - def polymorphic_name_association - reflection&.polymorphic_name - end - - def through_association? - reflection&.through_reflection? - end - def reflect_on_aggregation(aggregation_name) klass&.reflect_on_aggregation(aggregation_name) end @@ -78,6 +63,6 @@ def predicate_builder attr_reader :arel_table private - attr_reader :klass, :reflection + attr_reader :klass end end From f0b7a2e5742e336b7f898899f077e45bf4c9622b Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Mon, 1 Sep 2025 12:21:44 +0100 Subject: [PATCH 0521/1075] Use send for private serializer klass Fix the warning message that was added in https://github.com/rails/rails/pull/55583 --- activejob/lib/active_job/serializers.rb | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/activejob/lib/active_job/serializers.rb b/activejob/lib/active_job/serializers.rb index 69277a492022e..f5c0e793ab4f8 100644 --- a/activejob/lib/active_job/serializers.rb +++ b/activejob/lib/active_job/serializers.rb @@ -78,10 +78,11 @@ def index_serializers if s.respond_to?(:klass) @serializers_index[s.klass] = s elsif s.respond_to?(:klass, true) + klass = s.send(:klass) ActiveJob.deprecator.warn(<<~MSG.squish) - #{s.klass.name}#klass method should be public. + #{klass.name}#klass method should be public. MSG - @serializers_index[s.send(:klass)] = s + @serializers_index[klass] = s end end end From 2715a2dd24883dfd26ec542fd284681e9d4df7b3 Mon Sep 17 00:00:00 2001 From: Andrew Novoselac Date: Mon, 1 Sep 2025 19:22:15 -0400 Subject: [PATCH 0522/1075] Improve deprecation message introduced in rails/rails@b6c472a The #klass method is defined on the serializer, not the klass, so they deprecation message should include the serializer. --- activejob/lib/active_job/serializers.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/activejob/lib/active_job/serializers.rb b/activejob/lib/active_job/serializers.rb index f5c0e793ab4f8..4833656b3e94a 100644 --- a/activejob/lib/active_job/serializers.rb +++ b/activejob/lib/active_job/serializers.rb @@ -80,7 +80,7 @@ def index_serializers elsif s.respond_to?(:klass, true) klass = s.send(:klass) ActiveJob.deprecator.warn(<<~MSG.squish) - #{klass.name}#klass method should be public. + #{s.class.name}#klass method should be public. MSG @serializers_index[klass] = s end From b7cd3018ef4c39fdc7fc0c22fb06b8a0cd035c12 Mon Sep 17 00:00:00 2001 From: Andrew Novoselac Date: Mon, 1 Sep 2025 19:16:34 -0400 Subject: [PATCH 0523/1075] Add custom active job serializers in a load hook rails/rails@b6c472a optimized looking up active job serializes by indexing them by their klass when they are added. Calling klass is likely to load autoloaded code. Currently custom serializers are added in an after_initialize hook, meaning we will load autoloaded code at boot time in development and test. Instead, let's defer adding serializers until ActiveJob::Base is loaded, so we don't eager load app code unecessarily in dev and test. --- activejob/lib/active_job/railtie.rb | 2 +- log/development.log | 0 railties/test/application/configuration_test.rb | 6 +++++- 3 files changed, 6 insertions(+), 2 deletions(-) create mode 100644 log/development.log diff --git a/activejob/lib/active_job/railtie.rb b/activejob/lib/active_job/railtie.rb index eda0c9d898f27..fc57af28e42a8 100644 --- a/activejob/lib/active_job/railtie.rb +++ b/activejob/lib/active_job/railtie.rb @@ -19,7 +19,7 @@ class Railtie < Rails::Railtie # :nodoc: end initializer "active_job.custom_serializers" do |app| - config.after_initialize do + ActiveSupport.on_load(:active_job) do custom_serializers = app.config.active_job.custom_serializers ActiveJob::Serializers.add_serializers custom_serializers end diff --git a/log/development.log b/log/development.log new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/railties/test/application/configuration_test.rb b/railties/test/application/configuration_test.rb index eecaa6eef7fe8..6a262785a12fb 100644 --- a/railties/test/application/configuration_test.rb +++ b/railties/test/application/configuration_test.rb @@ -3250,6 +3250,10 @@ def klass app "development" + assert_nothing_raised do + ActiveJob::Base + end + assert_includes ActiveJob::Serializers.serializers, DummySerializer.instance end @@ -3268,7 +3272,7 @@ def klass end test "config.active_job.enqueue_after_transaction_commit is deprecated" do - app_file "config/initializers/custom_serializers.rb", <<-RUBY + app_file "config/initializers/enqueue_after_transaction_commit.rb", <<-RUBY Rails.application.config.active_job.enqueue_after_transaction_commit = :always RUBY From d2da81670c0049aeb2b7b062a80dd069ee2e725a Mon Sep 17 00:00:00 2001 From: Aaron Patterson Date: Tue, 2 Sep 2025 13:00:07 +0200 Subject: [PATCH 0524/1075] Freeze database types This commit freezes database type objects. These objects are cached in globals, and this commit will get us closer to Ractor shareability --- .../connection_adapters/abstract_adapter.rb | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb b/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb index 1a8e47aa7ba59..488cb18fb57c4 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb @@ -878,7 +878,7 @@ class << self def register_class_with_precision(mapping, key, klass, **kwargs) # :nodoc: mapping.register_type(key) do |*args| precision = extract_precision(args.last) - klass.new(precision: precision, **kwargs) + klass.new(precision: precision, **kwargs).freeze end end @@ -913,7 +913,7 @@ def initialize_type_map(m) m.alias_type %r(number)i, "decimal" m.alias_type %r(double)i, "float" - m.register_type %r(^json)i, Type::Json.new + m.register_type %r(^json)i, Type::Json.new.freeze m.register_type(%r(decimal)i) do |sql_type| scale = extract_scale(sql_type) @@ -921,9 +921,9 @@ def initialize_type_map(m) if scale == 0 # FIXME: Remove this class as well - Type::DecimalWithoutScale.new(precision: precision) + Type::DecimalWithoutScale.new(precision: precision).freeze else - Type::Decimal.new(precision: precision, scale: scale) + Type::Decimal.new(precision: precision, scale: scale).freeze end end end @@ -931,7 +931,7 @@ def initialize_type_map(m) def register_class_with_limit(mapping, key, klass) mapping.register_type(key) do |*args| limit = extract_limit(args.last) - klass.new(limit: limit) + klass.new(limit: limit).freeze end end From 8840ad8a5f842eb2c77c1afc22bc9193fe642ece Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 2 Sep 2025 15:08:29 +0200 Subject: [PATCH 0525/1075] Revert "Rails New: Only add browser restrictions when using importmap" (#55608) --- railties/CHANGELOG.md | 4 ---- .../templates/app/controllers/application_controller.rb.tt | 4 +++- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/railties/CHANGELOG.md b/railties/CHANGELOG.md index ae25e27bb1cca..0e95484726348 100644 --- a/railties/CHANGELOG.md +++ b/railties/CHANGELOG.md @@ -124,8 +124,4 @@ *Petrik de Heus* -* Only add browser restrictions for a new Rails app when using importmap. - - *Lucas Dohmen* - Please check [8-0-stable](https://github.com/rails/rails/blob/8-0-stable/railties/CHANGELOG.md) for previous changes. diff --git a/railties/lib/rails/generators/rails/app/templates/app/controllers/application_controller.rb.tt b/railties/lib/rails/generators/rails/app/templates/app/controllers/application_controller.rb.tt index 32547dd16b0f7..ce0ef8d515c41 100644 --- a/railties/lib/rails/generators/rails/app/templates/app/controllers/application_controller.rb.tt +++ b/railties/lib/rails/generators/rails/app/templates/app/controllers/application_controller.rb.tt @@ -1,9 +1,11 @@ class ApplicationController < ActionController::<%= options.api? ? "API" : "Base" %> -<%- if using_importmap? -%> +<%- unless options.api? -%> # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has. allow_browser versions: :modern +<%- if using_importmap? -%> # Changes to the importmap will invalidate the etag for HTML responses stale_when_importmap_changes <% end -%> +<% end -%> end From 73ecd0ced634e5177496677a2986ec3731c7e2ee Mon Sep 17 00:00:00 2001 From: Sean Doyle Date: Sun, 29 Sep 2024 16:17:06 -0400 Subject: [PATCH 0526/1075] RateLimiting: raise `ActionController::TooManyRequests` error Prior to this commit, the default behavior for exceeding a rate limit involved responding with a status of [429 Too many requests][]. The only indication that a rate limit was exceeded was the `429` code. This commit changes the default behavior from a `head :too_many_requests` call to instead raise a new `ActionController::TooManyRequests` error. This change adheres more closely to precedent established by other status codes like `ActionController::BadRequest` to a `:bad_request` (`400 Bad Request`) status, or `ActiveRecord::RecordNotFound` to a `:not_found` (`404 Not Found`) status. Application-side controllers can be configured to `rescue_from` that exception, or they can rely on a new `ActionController::TooManyRequests`-to-`429 Too many requests` status mapping entry in the `ActionDispatch/Middleware/ExceptionWrapper` error-to-status mapping. [429 Too many requests]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/429 --- actionpack/CHANGELOG.md | 7 ++++ .../lib/action_controller/metal/exceptions.rb | 5 +++ .../action_controller/metal/rate_limiting.rb | 7 ++-- .../middleware/exception_wrapper.rb | 1 + .../test/controller/api/rate_limiting_test.rb | 5 ++- .../test/controller/rate_limiting_test.rb | 40 +++++++++++-------- .../test/dispatch/show_exceptions_test.rb | 10 +++++ 7 files changed, 53 insertions(+), 22 deletions(-) diff --git a/actionpack/CHANGELOG.md b/actionpack/CHANGELOG.md index 809dc35cd73d0..ce28bab635d13 100644 --- a/actionpack/CHANGELOG.md +++ b/actionpack/CHANGELOG.md @@ -1,3 +1,10 @@ +* Raise `ActionController::TooManyRequests` error from `ActionController::RateLimiting` + + Requests that exceed the rate limit raise an `ActionController::TooManyRequests` error. + By default, Action Dispatch rescues the error and responds with a `429 Too Many Requests` status. + + *Sean Doyle* + * Add .md/.markdown as Markdown extensions and add a default `markdown:` renderer: ```ruby diff --git a/actionpack/lib/action_controller/metal/exceptions.rb b/actionpack/lib/action_controller/metal/exceptions.rb index 35dd1a9138eaf..bed189eb77396 100644 --- a/actionpack/lib/action_controller/metal/exceptions.rb +++ b/actionpack/lib/action_controller/metal/exceptions.rb @@ -103,4 +103,9 @@ def initialize(message, controller, action_name) super(message) end end + + # Raised when a Rate Limit is exceeded by too many requests within a period of + # time. + class TooManyRequests < ActionControllerError + end end diff --git a/actionpack/lib/action_controller/metal/rate_limiting.rb b/actionpack/lib/action_controller/metal/rate_limiting.rb index d45031ad311de..a53fa6761ca1e 100644 --- a/actionpack/lib/action_controller/metal/rate_limiting.rb +++ b/actionpack/lib/action_controller/metal/rate_limiting.rb @@ -22,8 +22,9 @@ module ClassMethods # share rate limits across multiple controllers, you can provide your own scope, # by passing value in the `scope:` parameter. # - # Requests that exceed the rate limit are refused with a `429 Too Many Requests` - # response. You can specialize this by passing a callable in the `with:` + # Requests that exceed the rate limit will raise an `ActionController::TooManyRequests` + # error. By default, Action Dispatch will rescue from the error and refuse the request + # with a `429 Too Many Requests` response. You can specialize this by passing a callable in the `with:` # parameter. It's evaluated within the context of the controller processing the # request. # @@ -57,7 +58,7 @@ module ClassMethods # rate_limit to: 3, within: 2.seconds, name: "short-term" # rate_limit to: 10, within: 5.minutes, name: "long-term" # end - def rate_limit(to:, within:, by: -> { request.remote_ip }, with: -> { head :too_many_requests }, store: cache_store, name: nil, scope: nil, **options) + def rate_limit(to:, within:, by: -> { request.remote_ip }, with: -> { raise TooManyRequests }, store: cache_store, name: nil, scope: nil, **options) before_action -> { rate_limiting(to: to, within: within, by: by, with: with, store: store, name: name, scope: scope || controller_path) }, **options end end diff --git a/actionpack/lib/action_dispatch/middleware/exception_wrapper.rb b/actionpack/lib/action_dispatch/middleware/exception_wrapper.rb index 8ec62b7b2744b..d6bd744dacf06 100644 --- a/actionpack/lib/action_dispatch/middleware/exception_wrapper.rb +++ b/actionpack/lib/action_dispatch/middleware/exception_wrapper.rb @@ -23,6 +23,7 @@ class ExceptionWrapper "ActionDispatch::Http::Parameters::ParseError" => :bad_request, "ActionController::BadRequest" => :bad_request, "ActionController::ParameterMissing" => :bad_request, + "ActionController::TooManyRequests" => :too_many_requests, "Rack::QueryParser::ParameterTypeError" => :bad_request, "Rack::QueryParser::InvalidParameterError" => :bad_request ) diff --git a/actionpack/test/controller/api/rate_limiting_test.rb b/actionpack/test/controller/api/rate_limiting_test.rb index 7710b285970b3..fa8da78ec3db9 100644 --- a/actionpack/test/controller/api/rate_limiting_test.rb +++ b/actionpack/test/controller/api/rate_limiting_test.rb @@ -23,8 +23,9 @@ class ApiRateLimitingTest < ActionController::TestCase get :limited_to_two assert_response :ok - get :limited_to_two - assert_response :too_many_requests + assert_raises ActionController::TooManyRequests do + get :limited_to_two + end end test "limit resets after time" do diff --git a/actionpack/test/controller/rate_limiting_test.rb b/actionpack/test/controller/rate_limiting_test.rb index 03b0835710133..40c4983f24eee 100644 --- a/actionpack/test/controller/rate_limiting_test.rb +++ b/actionpack/test/controller/rate_limiting_test.rb @@ -66,8 +66,9 @@ class RateLimitingTest < ActionController::TestCase get :limited assert_response :ok - get :limited - assert_response :too_many_requests + assert_raises ActionController::TooManyRequests do + get :limited + end end test "notification on limit action" do @@ -80,25 +81,27 @@ class RateLimitingTest < ActionController::TestCase within: 2.seconds, name: nil, by: request.remote_ip) do - get :limited + assert_raises ActionController::TooManyRequests do + get :limited + end end end test "multiple rate limits" do + freeze_time get :limited get :limited assert_response :ok - travel_to 3.seconds.from_now do - get :limited - get :limited - assert_response :ok - end + travel 3.seconds + get :limited + get :limited + assert_response :ok - travel_to 3.seconds.from_now do - get :limited + travel 3.seconds + get :limited + assert_raises ActionController::TooManyRequests do get :limited - assert_response :too_many_requests end end @@ -140,13 +143,15 @@ class RateLimitingTest < ActionController::TestCase @controller = RateLimitedSharedTwoController.new - get :limited_shared_two - assert_response :too_many_requests + assert_raises ActionController::TooManyRequests do + get :limited_shared_two + end @controller = RateLimitedSharedOneController.new - get :limited_shared_one - assert_response :too_many_requests + assert_raises ActionController::TooManyRequests do + get :limited_shared_one + end ensure RateLimitedBaseController.cache_store.clear end @@ -166,8 +171,9 @@ class RateLimitingTest < ActionController::TestCase @controller = RateLimitedSharedThreeController.new - get :limited_shared_three - assert_response :too_many_requests + assert_raises ActionController::TooManyRequests do + get :limited_shared_three + end ensure RateLimitedSharedController.cache_store.clear end diff --git a/actionpack/test/dispatch/show_exceptions_test.rb b/actionpack/test/dispatch/show_exceptions_test.rb index 1cb70be82b7fc..05a33bfddadea 100644 --- a/actionpack/test/dispatch/show_exceptions_test.rb +++ b/actionpack/test/dispatch/show_exceptions_test.rb @@ -27,6 +27,8 @@ def call(env) rescue raise ActionView::Template::Error.new("template") end + when "/rate_limited" + raise ActionController::TooManyRequests.new else raise "puke!" end @@ -41,6 +43,10 @@ def setup assert_raise RuntimeError do get "/", env: { "action_dispatch.show_exceptions" => :none } end + + assert_raise ActionController::TooManyRequests do + get "/rate_limited", headers: { "action_dispatch.show_exceptions" => :none } + end end test "rescue with error page" do @@ -67,6 +73,10 @@ def setup get "/invalid_mimetype", headers: { "Accept" => "text/html,*", "action_dispatch.show_exceptions" => :all } assert_response 406 assert_equal "", body + + get "/rate_limited", headers: { "action_dispatch.show_exceptions" => :all } + assert_response 429 + assert_equal "", body end test "rescue with no body for HEAD requests" do From 88044fd5203cdbc1ce947dda403064571391a58a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Tue, 2 Sep 2025 13:54:09 +0000 Subject: [PATCH 0527/1075] Remove unneeded file --- log/development.log | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 log/development.log diff --git a/log/development.log b/log/development.log deleted file mode 100644 index e69de29bb2d1d..0000000000000 From d38902f2d9a25f176c4b42d9875f43a1de203ee5 Mon Sep 17 00:00:00 2001 From: Matthew Draper Date: Mon, 15 Apr 2024 15:15:07 +1000 Subject: [PATCH 0528/1075] Move raw-connection-mangling methods up to adapter helpers We should still avoid using them whenever possible, but e.g. I'd like to be able to break connections inside a pool test. --- activerecord/test/cases/adapter_test.rb | 75 +------------------- activerecord/test/support/adapter_helper.rb | 76 +++++++++++++++++++++ 2 files changed, 79 insertions(+), 72 deletions(-) diff --git a/activerecord/test/cases/adapter_test.rb b/activerecord/test/cases/adapter_test.rb index 8e602216be537..0bd7954f55fee 100644 --- a/activerecord/test/cases/adapter_test.rb +++ b/activerecord/test/cases/adapter_test.rb @@ -882,13 +882,13 @@ def teardown end test "#execute is retryable" do - initial_connection_id = connection_id_from_server + initial_connection_id = connection_id_from_server(@connection) kill_connection_from_server(initial_connection_id) @connection.execute("SELECT 1", allow_retry: true) - assert_not_equal initial_connection_id, connection_id_from_server + assert_not_equal initial_connection_id, connection_id_from_server(@connection) end test "disconnect and recover on #configure_connection failure" do @@ -930,79 +930,10 @@ def teardown end assert_equal [[1]], connection.exec_query("SELECT 1").rows - assert_empty failures + assert_empty slow ensure connection&.disconnect! end - - private - def raw_transaction_open?(connection) - case connection.adapter_name - when "PostgreSQL" - connection.instance_variable_get(:@raw_connection).transaction_status == ::PG::PQTRANS_INTRANS - when "Mysql2", "Trilogy" - begin - connection.instance_variable_get(:@raw_connection).query("SAVEPOINT transaction_test") - connection.instance_variable_get(:@raw_connection).query("RELEASE SAVEPOINT transaction_test") - - true - rescue - false - end - when "SQLite" - begin - connection.instance_variable_get(:@raw_connection).transaction { nil } - false - rescue - true - end - else - skip("raw_transaction_open? unsupported") - end - end - - def remote_disconnect(connection) - case connection.adapter_name - when "PostgreSQL" - # Connection was left in a bad state, need to reconnect to simulate fresh disconnect - connection.verify! if connection.instance_variable_get(:@raw_connection).status == ::PG::CONNECTION_BAD - unless connection.instance_variable_get(:@raw_connection).transaction_status == ::PG::PQTRANS_INTRANS - connection.instance_variable_get(:@raw_connection).async_exec("begin") - end - connection.instance_variable_get(:@raw_connection).async_exec("set idle_in_transaction_session_timeout = '10ms'") - sleep 0.05 - when "Mysql2", "Trilogy" - connection.send(:internal_execute, "set @@wait_timeout=1", materialize_transactions: false) - sleep 1.2 - else - skip("remote_disconnect unsupported") - end - end - - def connection_id_from_server - case @connection.adapter_name - when "Mysql2", "Trilogy" - @connection.execute("SELECT CONNECTION_ID()").to_a[0][0] - when "PostgreSQL" - @connection.execute("SELECT pg_backend_pid()").to_a[0]["pg_backend_pid"] - else - skip("connection_id_from_server unsupported") - end - end - - def kill_connection_from_server(connection_id) - conn = @connection.pool.checkout - case conn.adapter_name - when "Mysql2", "Trilogy" - conn.execute("KILL #{connection_id}") - when "PostgreSQL" - conn.execute("SELECT pg_terminate_backend(#{connection_id})") - else - skip("kill_connection_from_server unsupported") - end - - conn.close - end end end diff --git a/activerecord/test/support/adapter_helper.rb b/activerecord/test/support/adapter_helper.rb index b22d460a3fe30..edf522fc74926 100644 --- a/activerecord/test/support/adapter_helper.rb +++ b/activerecord/test/support/adapter_helper.rb @@ -101,4 +101,80 @@ def disable_extension!(extension, connection) connection.disable_extension(extension, force: :cascade) connection.reconnect! end + + # Detects whether the server side of the connection physically has a + # transaction open, independently of the adapter's opinion. Skips if we don't + # know how to detect this. + def raw_transaction_open?(connection) + if current_adapter?(:PostgreSQLAdapter) + connection.instance_variable_get(:@raw_connection).transaction_status == ::PG::PQTRANS_INTRANS + elsif current_adapter?(:Mysql2Adapter, :TrilogyAdapter) + begin + connection.instance_variable_get(:@raw_connection).query("SAVEPOINT transaction_test") + connection.instance_variable_get(:@raw_connection).query("RELEASE SAVEPOINT transaction_test") + + true + rescue + false + end + elsif current_adapter?(:SQLite3Adapter) + begin + connection.instance_variable_get(:@raw_connection).transaction { nil } + false + rescue + true + end + else + skip("raw_transaction_open? unsupported") + end + end + + # Arrange for the server to disconnect the connection, leaving it broken (by + # setting, and then sleeping to exceed, a very short timeout). Skips if we + # can't do so. + def remote_disconnect(connection) + if current_adapter?(:PostgreSQLAdapter) + # Connection was left in a bad state, need to reconnect to simulate fresh disconnect + connection.verify! if connection.instance_variable_get(:@raw_connection).status == ::PG::CONNECTION_BAD + unless connection.instance_variable_get(:@raw_connection).transaction_status == ::PG::PQTRANS_INTRANS + connection.instance_variable_get(:@raw_connection).async_exec("begin") + end + connection.instance_variable_get(:@raw_connection).async_exec("set idle_in_transaction_session_timeout = '10ms'") + sleep 0.05 + elsif current_adapter?(:Mysql2Adapter, :TrilogyAdapter) + connection.send(:internal_execute, "set @@wait_timeout=1", materialize_transactions: false) + sleep 1.2 + else + skip("remote_disconnect unsupported") + end + end + + def connection_id_from_server(connection) + case connection.adapter_name + when "Mysql2", "Trilogy" + connection.execute("SELECT CONNECTION_ID()").to_a[0][0] + when "PostgreSQL" + connection.execute("SELECT pg_backend_pid()").to_a[0]["pg_backend_pid"] + else + skip("connection_id_from_server unsupported") + end + end + + # Uses a separate connection to admin-kill the connection with the given ID + # from the server side. Skips if we can't do so. + def kill_connection_from_server(connection_id, pool = ActiveRecord::Base.connection_pool) + actor_connection = pool.checkout + pool.remove(actor_connection) + + case actor_connection.adapter_name + when "Mysql2", "Trilogy" + actor_connection.execute("KILL #{connection_id}") + when "PostgreSQL" + actor_connection.execute("SELECT pg_terminate_backend(#{connection_id})") + else + skip("kill_connection_from_server unsupported") + end + + actor_connection.close + end end From c8365edd8ca4aa30940086bf44757efc7494914b Mon Sep 17 00:00:00 2001 From: Matthew Draper Date: Mon, 15 Apr 2024 15:26:23 +1000 Subject: [PATCH 0529/1075] Override isolation level before common test setup The shared setup method creates a connection pool, and having the isolation level change after a pool is created makes things awkward. This has slightly more duplication, but not enough that I feel the need to seek a different abstraction. --- .../test/cases/connection_pool_test.rb | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/activerecord/test/cases/connection_pool_test.rb b/activerecord/test/cases/connection_pool_test.rb index f99aad49949f2..ebfde15916b33 100644 --- a/activerecord/test/cases/connection_pool_test.rb +++ b/activerecord/test/cases/connection_pool_test.rb @@ -14,8 +14,6 @@ def self.included(test) attr_reader :pool def setup - @previous_isolation_level = ActiveSupport::IsolatedExecutionState.isolation_level - # Keep a duplicate pool so we do not bother others config = ActiveRecord::Base.connection_pool.db_config @db_config = ActiveRecord::DatabaseConfigurations::HashConfig.new( @@ -43,7 +41,6 @@ def setup def teardown super @pool.disconnect! - ActiveSupport::IsolatedExecutionState.isolation_level = @previous_isolation_level end def test_checkout_after_close @@ -1019,9 +1016,17 @@ class ThreadConnectionTestModel < ActiveRecord::Base end def setup - super + @previous_isolation_level = ActiveSupport::IsolatedExecutionState.isolation_level + ActiveSupport::IsolatedExecutionState.isolation_level = :thread @connection_test_model_class = ThreadConnectionTestModel + + super + end + + def teardown + super + ActiveSupport::IsolatedExecutionState.isolation_level = @previous_isolation_level end def test_lock_thread_allow_fiber_reentrency @@ -1077,9 +1082,17 @@ def kill end def setup - super + @previous_isolation_level = ActiveSupport::IsolatedExecutionState.isolation_level + ActiveSupport::IsolatedExecutionState.isolation_level = :fiber @connection_test_model_class = FiberConnectionTestModel + + super + end + + def teardown + super + ActiveSupport::IsolatedExecutionState.isolation_level = @previous_isolation_level end private From 5eab03f7b0e8a12871bbe3929a5297491915f586 Mon Sep 17 00:00:00 2001 From: Matthew Draper Date: Mon, 15 Apr 2024 15:41:31 +1000 Subject: [PATCH 0530/1075] Allow connections to be borrowed out of the pool for maintenance --- .../abstract/connection_pool.rb | 77 ++++++++++++++++++- .../abstract/connection_pool/queue.rb | 8 ++ .../connection_adapters/abstract_adapter.rb | 4 +- 3 files changed, 84 insertions(+), 5 deletions(-) diff --git a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb index c158043c938c8..9cb70452e8d0b 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb @@ -119,6 +119,7 @@ def pool_transaction_isolation_level=(isolation_level) # * access to these instance variables needs to be in +synchronize+: # * @connections # * @now_connecting + # * @maintaining # * private methods that require being called in a +synchronize+ blocks # are now explicitly documented class ConnectionPool @@ -266,6 +267,12 @@ def initialize(pool_config) # currently in the process of independently establishing connections to the DB. @now_connecting = 0 + # Sometimes otherwise-idle connections are temporarily held by the Reaper for + # maintenance. This variable tracks the number of connections currently in that + # state -- if a thread requests a connection and there are none available, it + # will await any in-maintenance connections in preference to creating a new one. + @maintaining = 0 + @threads_blocking_new_connections = 0 @available = ConnectionLeasingQueue.new self @@ -631,7 +638,7 @@ def remove(conn) # that are "stuck" there are helpless. They have no way of creating # new connections and are completely reliant on us feeding available # connections into the Queue. - needs_new_connection = @available.any_waiting? + needs_new_connection = @available.num_waiting > @maintaining end # This is intentionally done outside of the synchronized section as we @@ -762,6 +769,45 @@ def build_async_executor end end + # Directly check a specific connection out of the pool. Skips callbacks. + # + # The connection must later either #return_from_maintenance or + # #remove_from_maintenance, or the pool will hang. + def checkout_for_maintenance(conn) + synchronize do + @maintaining += 1 + @available.delete(conn) + conn.lease + conn + end + end + + # Return a connection to the pool after it has been checked out for + # maintenance. Does not update the connection's idle time, and skips + # callbacks. + #-- + # We assume that a connection that has required maintenance is less + # desirable (either it's been idle for a long time, or it was just + # created and hasn't been used yet). We'll put it at the back of the + # queue. + def return_from_maintenance(conn) + synchronize do + conn.expire(false) + @available.add_back(conn) + @maintaining -= 1 + end + end + + # Remove a connection from the pool after it has been checked out for + # maintenance. It will be automatically replaced with a new connection if + # necessary. + def remove_from_maintenance(conn) + synchronize do + @maintaining -= 1 + remove conn + end + end + #-- # this is unfortunately not concurrent def bulk_make_new_connections(num_new_conns_needed) @@ -901,13 +947,13 @@ def acquire_connection(checkout_timeout) # synchronize { conn.lease } in this method, but by leaving it to @available.poll # and +try_to_checkout_new_connection+ we can piggyback on +synchronize+ sections # of the said methods and avoid an additional +synchronize+ overhead. - if conn = @available.poll || try_to_checkout_new_connection + if conn = @available.poll || try_to_queue_for_background_connection(checkout_timeout) || try_to_checkout_new_connection conn else reap # Retry after reaping, which may return an available connection, # remove an inactive connection, or both - if conn = @available.poll || try_to_checkout_new_connection + if conn = @available.poll || try_to_queue_for_background_connection(checkout_timeout) || try_to_checkout_new_connection conn else @available.poll(checkout_timeout) @@ -917,6 +963,31 @@ def acquire_connection(checkout_timeout) raise ex.set_pool(self) end + #-- + # If new connections are already being established in the background, + # and there are fewer threads already waiting than the number of + # upcoming connections, we can just get in queue and wait to be handed a + # connection. This avoids us overshooting the required connection count + # by starting a new connection ourselves, and is likely to be faster + # too (because at least some of the time it takes to establish a new + # connection must have already passed). + # + # If background connections are available, this method will block and + # return a connection. If no background connections are available, it + # will immediately return +nil+. + def try_to_queue_for_background_connection(checkout_timeout) + return unless @maintaining > 0 + + synchronize do + return unless @maintaining > @available.num_waiting + + # We are guaranteed the "maintaining" thread will return its promised + # connection within one maintenance-unit of time. Thus we can safely + # do a blocking wait with (functionally) no timeout. + @available.poll(100) + end + end + #-- # if owner_thread param is omitted, this must be called in synchronize block def remove_connection_from_thread_cache(conn, owner_thread = conn.owner) diff --git a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool/queue.rb b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool/queue.rb index 8f8f9d53fab46..7de65e151edd9 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool/queue.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool/queue.rb @@ -40,6 +40,14 @@ def add(element) end end + # Add +element+ to the back of the queue. Never blocks. + def add_back(element) + synchronize do + @queue.unshift element + @cond.signal + end + end + # If +element+ is in the queue, remove and return it, or +nil+. def delete(element) synchronize do diff --git a/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb b/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb index 488cb18fb57c4..3a5f950afa601 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb @@ -304,7 +304,7 @@ def schema_cache end # this method must only be called while holding connection pool's mutex - def expire + def expire(update_idle = true) # :nodoc: if in_use? if @owner != ActiveSupport::IsolatedExecutionState.context raise ActiveRecordError, "Cannot expire connection, " \ @@ -312,7 +312,7 @@ def expire "Current thread: #{ActiveSupport::IsolatedExecutionState.context}." end - @idle_since = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @idle_since = Process.clock_gettime(Process::CLOCK_MONOTONIC) if update_idle @owner = nil else raise ActiveRecordError, "Cannot expire connection, it is not currently leased." From a8b44fdd011f55271b068efa7af68061dfa7b74d Mon Sep 17 00:00:00 2001 From: Matthew Draper Date: Mon, 15 Apr 2024 15:45:09 +1000 Subject: [PATCH 0531/1075] Simplify the construction of once-off pool configurations In the process, be more consistent about always disconnecting pools we've created. --- .../test/cases/connection_pool_test.rb | 31 +++++++++++++------ 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/activerecord/test/cases/connection_pool_test.rb b/activerecord/test/cases/connection_pool_test.rb index ebfde15916b33..8c02fbb1c2f63 100644 --- a/activerecord/test/cases/connection_pool_test.rb +++ b/activerecord/test/cases/connection_pool_test.rb @@ -27,6 +27,8 @@ def setup @pool_config = ActiveRecord::ConnectionAdapters::PoolConfig.new(ActiveRecord::Base, @db_config, :writing, :default) @pool = ConnectionPool.new(@pool_config) + @pools = [@pool] + if in_memory_db? # Separate connections to an in-memory database create an entirely new database, # with an empty schema etc, so we just stub out this schema on the fly. @@ -40,7 +42,7 @@ def setup def teardown super - @pool.disconnect! + @pools&.each(&:disconnect!) end def test_checkout_after_close @@ -259,11 +261,7 @@ def test_inactive_are_returned_from_dead_thread def test_idle_timeout_configuration @pool.disconnect! - config = @db_config.configuration_hash.merge(idle_timeout: "0.02") - db_config = ActiveRecord::DatabaseConfigurations::HashConfig.new(@db_config.env_name, @db_config.name, config) - - pool_config = ActiveRecord::ConnectionAdapters::PoolConfig.new(ActiveRecord::Base, db_config, :writing, :default) - @pool = ConnectionPool.new(pool_config) + @pool = new_pool_with_options(idle_timeout: "0.02") idle_conn = @pool.checkout @pool.checkin(idle_conn) @@ -287,10 +285,7 @@ def test_idle_timeout_configuration def test_disable_flush @pool.disconnect! - config = @db_config.configuration_hash.merge(idle_timeout: -5) - db_config = ActiveRecord::DatabaseConfigurations::HashConfig.new(@db_config.env_name, @db_config.name, config) - pool_config = ActiveRecord::ConnectionAdapters::PoolConfig.new(ActiveRecord::Base, db_config, :writing, :default) - @pool = ConnectionPool.new(pool_config) + @pool = new_pool_with_options(idle_timeout: -5) idle_conn = @pool.checkout @pool.checkin(idle_conn) @@ -398,6 +393,8 @@ def test_checkout_behavior assert pool.lease_connection pool.lease_connection.close end.join + ensure + pool&.disconnect! end def test_checkout_order_is_lifo @@ -534,6 +531,8 @@ def test_automatic_reconnect_restores_after_disconnect pool.disconnect! assert pool.lease_connection + ensure + pool&.disconnect! end def test_automatic_reconnect_can_be_disabled @@ -548,6 +547,8 @@ def test_automatic_reconnect_can_be_disabled assert_raises(ConnectionNotEstablished) do pool.with_connection end + ensure + pool&.disconnect! end def test_pool_sets_connection_visitor @@ -1006,6 +1007,16 @@ def with_single_connection_pool(**options) ensure pool.disconnect! if pool end + + def new_pool_with_options(async: true, **options) + config = @db_config.configuration_hash.merge(options) + db_config = ActiveRecord::DatabaseConfigurations::HashConfig.new(@db_config.env_name, @db_config.name, config) + pool_config = ActiveRecord::ConnectionAdapters::PoolConfig.new(ActiveRecord::Base, db_config, :writing, :default) + pool = ConnectionPool.new(pool_config) + pool.instance_variable_set(:@async_executor, nil) unless async + @pools << pool + pool + end end class ConnectionPoolThreadTest < ActiveRecord::TestCase From a27a3bd8925c98d1eea10d826471b488ee952468 Mon Sep 17 00:00:00 2001 From: Matthew Draper Date: Mon, 15 Apr 2024 15:50:07 +1000 Subject: [PATCH 0532/1075] Allow a connection pool to iteratively maintain its connections Building upon the "checkout for maintenance" primitives, this gives us a method to progressively loop over idle connections without the risk of starving the pool while we work. Even for something internal, I don't love the API... but hopefully it will suffice until we come up with something better. --- .../abstract/connection_pool.rb | 63 ++++++++ .../abstract/connection_pool/queue.rb | 7 + .../test/cases/connection_pool_test.rb | 142 ++++++++++++++++++ 3 files changed, 212 insertions(+) diff --git a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb index 9cb70452e8d0b..9b3d00c4f5acb 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb @@ -706,6 +706,10 @@ def num_waiting_in_queue # :nodoc: @available.num_waiting end + def num_available_in_queue # :nodoc: + @available.size + end + # Returns the connection pool's usage statistic. # # ActiveRecord::Base.connection_pool.stat # => { size: 15, connections: 1, busy: 1, dead: 0, idle: 0, waiting: 0, checkout_timeout: 5 } @@ -769,6 +773,65 @@ def build_async_executor end end + # Perform maintenance work on pool connections. This method will + # select a connection to work on by calling the +candidate_selector+ + # proc while holding the pool lock. If a connection is selected, it + # will be checked out for maintenance and passed to the + # +maintenance_work+ proc. The connection will always be returned to + # the pool after the proc completes. + # + # If the pool has async threads, all work will be scheduled there. + # Otherwise, this method will block until all work is complete. + # + # Each connection will only be processed once per call to this method, + # but (particularly in the async case) there is no protection against + # a second call to this method starting to work through the list + # before the first call has completed. (Though regular pool behaviour + # will prevent two instances from working on the same specific + # connection at the same time.) + def sequential_maintenance(candidate_selector, &maintenance_work) + # This hash doesn't need to be synchronized, because it's only + # used by one thread at a time: the +perform_work+ block gives + # up its right to +connections_visited+ when it schedules the + # next iteration. + connections_visited = Hash.new(false) + connections_visited.compare_by_identity + + perform_work = lambda do + connection_to_maintain = nil + + synchronize do + unless self.discarded? + if connection_to_maintain = @connections.select { |conn| !conn.in_use? }.select(&candidate_selector).sort_by(&:seconds_idle).find { |conn| !connections_visited[conn] } + checkout_for_maintenance connection_to_maintain + end + end + end + + if connection_to_maintain + connections_visited[connection_to_maintain] = true + + # If we're running async, we can schedule the next round of work + # as soon as we've grabbed a connection to work on. + @async_executor&.post(&perform_work) + + begin + maintenance_work.call connection_to_maintain + ensure + return_from_maintenance connection_to_maintain + end + + true + end + end + + if @async_executor + @async_executor.post(&perform_work) + else + nil while perform_work.call + end + end + # Directly check a specific connection out of the pool. Skips callbacks. # # The connection must later either #return_from_maintenance or diff --git a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool/queue.rb b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool/queue.rb index 7de65e151edd9..ef9e2156cc6d2 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool/queue.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool/queue.rb @@ -62,6 +62,13 @@ def clear end end + # Number of elements in the queue. + def size + synchronize do + @queue.size + end + end + # Remove the head of the queue. # # If +timeout+ is not given, remove and return the head of the diff --git a/activerecord/test/cases/connection_pool_test.rb b/activerecord/test/cases/connection_pool_test.rb index 8c02fbb1c2f63..f9a22e4c2f644 100644 --- a/activerecord/test/cases/connection_pool_test.rb +++ b/activerecord/test/cases/connection_pool_test.rb @@ -555,6 +555,88 @@ def test_pool_sets_connection_visitor assert @pool.lease_connection.visitor.is_a?(Arel::Visitors::ToSql) end + #-- + # This is testing a private method... but testing its public + # callers would be much more complicated, as well as needing + # duplicate coverage. + def test_sequential_maintenance_loop_is_incremental + work_queue = [] + work_collector = Object.new + work_collector.define_singleton_method(:post) do |&block| + work_queue << block + end + + completion_list = [] + selection_list = [] + + @pool.instance_variable_set(:@max_size, 3) + @pool.instance_variable_set(:@async_executor, work_collector) + + 3.times.map { @pool.checkout }.each do |conn| + @pool.checkin conn + end + + selector = lambda do |conn| + assert_not_predicate conn, :in_use? + assert_equal 3, @pool.num_available_in_queue + + selection_list << conn + true + end + + pool.send(:sequential_maintenance, selector) do |conn| + assert_predicate conn, :in_use? + assert_equal 2, @pool.num_available_in_queue + + completion_list << conn + end + + # final iteration determines there's no more work to do + 4.times do + assert_equal 1, work_queue.size + work_queue.shift.call + assert_equal 3, @pool.num_available_in_queue + end + assert_equal 0, work_queue.size + + assert_equal 3, completion_list.size + assert_equal 3 * 4, selection_list.size + end + + def test_sequential_maintenance_can_run_inline + completion_list = [] + selection_list = [] + + @pool.instance_variable_set(:@max_size, 3) + @pool.instance_variable_set(:@async_executor, nil) + + 3.times.map { @pool.checkout }.each do |conn| + @pool.checkin conn + end + + selector = lambda do |conn| + assert_not_predicate conn, :in_use? + assert_equal 3, @pool.num_available_in_queue + + selection_list << conn + true + end + + pool.send(:sequential_maintenance, selector) do |conn| + assert_predicate conn, :in_use? + assert_equal 2, @pool.num_available_in_queue + + completion_list << conn + end + + assert_equal 3, @pool.num_available_in_queue + + assert_equal 3, completion_list.size + + # final iteration determines there's no more work to do + assert_equal 3 * 4, selection_list.size + end + # make sure exceptions are thrown when establish_connection # is called with an anonymous class def test_anonymous_class_exception @@ -663,6 +745,66 @@ def test_concurrent_connection_establishment end end + def test_checkout_queues_behind_maintenance_connections + skip_fiber_testing + + pool = new_pool_with_options(max_connections: 3, reaping_frequency: nil, async: false) + + # Set up pool with 2 connections: one available, one checked out + conn1 = pool.checkout + conn2 = pool.checkout + pool.checkin(conn1) + + maintenance_started = Concurrent::Event.new + maintenance_continuing = Concurrent::Event.new + checkout_complete = Concurrent::Event.new + + maintenance_thread = new_thread do + n = 0 + + pool.send(:sequential_maintenance, proc { true }) do |_| + maintenance_started.set + maintenance_continuing.wait + + n += 1 + end + + n + end + + maintenance_started.wait + + checkout_thread = new_thread do + conn = pool.checkout # blocks waiting for the in-maintenance connection + + checkout_complete.set + pool.checkin(conn) + conn + end + + # Give the checkout attempt time to start blocking + sleep 0.01 + + # checkout_thread is now waiting; #checkout has not returned + assert_equal 1, pool.num_waiting_in_queue + assert_not checkout_complete.set? + + # Release maintenance, allowing checkout to occur + maintenance_continuing.set + + # After checkout_thread is complete: confirm it got the connection we + # were previously maintaining + assert_equal conn1, checkout_thread.value + + # Correspondingly, no new third connection was created + assert_equal 2, pool.connections.size + + # Maintenance only visited the available connection (and only once) + assert_equal 1, maintenance_thread.value + + pool.checkin(conn2) + end + def test_non_bang_disconnect_and_clear_reloadable_connections_throw_exception_if_threads_dont_return_their_conns Thread.report_on_exception, original_report_on_exception = false, Thread.report_on_exception @pool.checkout_timeout = 0.001 # no need to delay test suite by waiting the whole full default timeout From 544ee944d4d7bcb7d7d15c0cec2458e10f08e4c7 Mon Sep 17 00:00:00 2001 From: Matthew Draper Date: Sun, 29 Sep 2024 19:46:47 -0400 Subject: [PATCH 0533/1075] Maintain a minimum pool size when expiring idle connections Co-authored-by: Chris AtLee --- .../abstract/connection_pool.rb | 42 +++++++++++++------ .../database_configurations/hash_config.rb | 4 ++ .../test/cases/connection_pool_test.rb | 36 ++++++++++++++-- 3 files changed, 66 insertions(+), 16 deletions(-) diff --git a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb index 9b3d00c4f5acb..871d85df0a98c 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb @@ -225,7 +225,8 @@ def install_executor_hooks(executor = ActiveSupport::Executor) include ConnectionAdapters::AbstractPool attr_accessor :automatic_reconnect, :checkout_timeout - attr_reader :db_config, :size, :reaper, :pool_config, :async_executor, :role, :shard + attr_reader :db_config, :max_size, :min_size, :reaper, :pool_config, :async_executor, :role, :shard + alias :size :max_size delegate :schema_reflection, :server_version, to: :pool_config @@ -245,7 +246,8 @@ def initialize(pool_config) @checkout_timeout = db_config.checkout_timeout @idle_timeout = db_config.idle_timeout - @size = db_config.pool + @max_size = db_config.pool + @min_size = db_config.min_size # This variable tracks the cache of threads mapped to reserved connections, with the # sole purpose of speeding up the +connection+ method. It is not the authoritative @@ -630,7 +632,7 @@ def remove(conn) @available.delete conn # @available.any_waiting? => true means that prior to removing this - # conn, the pool was at its max size (@connections.size == @size). + # conn, the pool was at its max size (@connections.size == @max_size). # This would mean that any threads stuck waiting in the queue wouldn't # know they could checkout_new_connection, so let's do it for them. # Because condition-wait loop is encapsulated in the Queue class @@ -645,7 +647,7 @@ def remove(conn) # would like not to hold the main mutex while checking out new connections. # Thus there is some chance that needs_new_connection information is now # stale, we can live with that (bulk_make_new_connections will make - # sure not to exceed the pool's @size limit). + # sure not to exceed the pool's @max_size limit). bulk_make_new_connections(1) if needs_new_connection end @@ -678,11 +680,27 @@ def reap def flush(minimum_idle = @idle_timeout) return if minimum_idle.nil? - idle_connections = synchronize do + removed_connections = synchronize do return if self.discarded? - @connections.select do |conn| + + idle_connections = @connections.select do |conn| !conn.in_use? && conn.seconds_idle >= minimum_idle - end.each do |conn| + end.sort_by { |conn| -conn.seconds_idle } # sort longest idle first + + # Don't go below our configured pool minimum unless we're flushing + # everything + idles_to_retain = + if minimum_idle > 0 + @min_size - (@connections.size - idle_connections.size) + else + 0 + end + + if idles_to_retain > 0 + idle_connections.pop idles_to_retain + end + + idle_connections.each do |conn| conn.lease @available.delete conn @@ -690,7 +708,7 @@ def flush(minimum_idle = @idle_timeout) end end - idle_connections.each do |conn| + removed_connections.each do |conn| conn.disconnect! end end @@ -875,7 +893,7 @@ def remove_from_maintenance(conn) # this is unfortunately not concurrent def bulk_make_new_connections(num_new_conns_needed) num_new_conns_needed.times do - # try_to_checkout_new_connection will not exceed pool's @size limit + # try_to_checkout_new_connection will not exceed pool's @max_size limit if new_conn = try_to_checkout_new_connection # make the new_conn available to the starving threads stuck @available Queue checkin(new_conn) @@ -1060,17 +1078,17 @@ def remove_connection_from_thread_cache(conn, owner_thread = conn.owner) end alias_method :release, :remove_connection_from_thread_cache - # If the pool is not at a @size limit, establish new connection. Connecting + # If the pool is not at a @max_size limit, establish new connection. Connecting # to the DB is done outside main synchronized section. #-- # Implementation constraint: a newly established connection returned by this # method must be in the +.leased+ state. def try_to_checkout_new_connection # first in synchronized section check if establishing new conns is allowed - # and increment @now_connecting, to prevent overstepping this pool's @size + # and increment @now_connecting, to prevent overstepping this pool's @max_size # constraint do_checkout = synchronize do - if @threads_blocking_new_connections.zero? && (@connections.size + @now_connecting) < @size + if @threads_blocking_new_connections.zero? && (@connections.size + @now_connecting) < @max_size @now_connecting += 1 end end diff --git a/activerecord/lib/active_record/database_configurations/hash_config.rb b/activerecord/lib/active_record/database_configurations/hash_config.rb index 71fb8f16f7d14..4b18ecdf659c0 100644 --- a/activerecord/lib/active_record/database_configurations/hash_config.rb +++ b/activerecord/lib/active_record/database_configurations/hash_config.rb @@ -73,6 +73,10 @@ def pool (configuration_hash[:pool] || 5).to_i end + def min_size + (configuration_hash[:min_size] || 0).to_i + end + def min_threads (configuration_hash[:min_threads] || 0).to_i end diff --git a/activerecord/test/cases/connection_pool_test.rb b/activerecord/test/cases/connection_pool_test.rb index f9a22e4c2f644..bd6d678ea6e41 100644 --- a/activerecord/test/cases/connection_pool_test.rb +++ b/activerecord/test/cases/connection_pool_test.rb @@ -149,7 +149,7 @@ def test_full_pool_blocks def test_full_pool_blocking_shares_load_interlock skip_fiber_testing - @pool.instance_variable_set(:@size, 1) + @pool.instance_variable_set(:@max_size, 1) load_interlock_latch = Concurrent::CountDownLatch.new connection_latch = Concurrent::CountDownLatch.new @@ -236,7 +236,7 @@ def test_reap_inactive def test_inactive_are_returned_from_dead_thread ready = Concurrent::CountDownLatch.new - @pool.instance_variable_set(:@size, 1) + @pool.instance_variable_set(:@max_size, 1) child = new_thread do @pool.checkout @@ -282,6 +282,34 @@ def test_idle_timeout_configuration assert_equal 0, @pool.connections.length end + def test_idle_timeout_configuration_with_min_size + @pool.disconnect! + + @pool = new_pool_with_options(idle_timeout: "0.02", min_size: 1) + connections = 2.times.map { @pool.checkout } + connections.each { |conn| @pool.checkin(conn) } + + connections.each do |conn| + conn.instance_variable_set( + :@idle_since, + Process.clock_gettime(Process::CLOCK_MONOTONIC) - 0.01 + ) + end + + @pool.flush + assert_equal 2, @pool.connections.length + + connections.each do |conn| + conn.instance_variable_set( + :@idle_since, + Process.clock_gettime(Process::CLOCK_MONOTONIC) - 0.03 + ) + end + + @pool.flush + assert_equal 1, @pool.connections.length + end + def test_disable_flush @pool.disconnect! @@ -421,7 +449,7 @@ def test_checkout_order_is_lifo def test_checkout_fairness skip_fiber_testing - @pool.instance_variable_set(:@size, 10) + @pool.instance_variable_set(:@max_size, 10) expected = (1..@pool.size).to_a.freeze # check out all connections so our threads start out waiting conns = expected.map { @pool.checkout } @@ -468,7 +496,7 @@ def test_checkout_fairness def test_checkout_fairness_by_group skip_fiber_testing - @pool.instance_variable_set(:@size, 10) + @pool.instance_variable_set(:@max_size, 10) # take all the connections conns = (1..10).map { @pool.checkout } mutex = Mutex.new From def98015f57b49df76b0e6dcc3fe91ef6ee00104 Mon Sep 17 00:00:00 2001 From: Matthew Draper Date: Sun, 29 Sep 2024 19:55:50 -0400 Subject: [PATCH 0534/1075] Proactively populate the pool up to minimum after it is activated Co-authored-by: Chris AtLee --- .../abstract/connection_pool.rb | 37 ++++++++++++++++++- .../abstract/connection_pool/reaper.rb | 1 + .../test/cases/connection_pool_test.rb | 10 +++++ activerecord/test/cases/reaper_test.rb | 3 ++ 4 files changed, 49 insertions(+), 2 deletions(-) diff --git a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb index 871d85df0a98c..1a27a7e4f52ab 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb @@ -285,6 +285,8 @@ def initialize(pool_config) @schema_cache = nil + @activated = false + @reaper = Reaper.new(self, db_config.reaping_frequency) @reaper.run end @@ -321,6 +323,14 @@ def internal_metadata # :nodoc: InternalMetadata.new(self) end + def activate + @activated = true + end + + def activated? + @activated + end + # Retrieve the connection associated with the current thread, or call # #checkout to obtain one if necessary. # @@ -714,10 +724,30 @@ def flush(minimum_idle = @idle_timeout) end # Disconnect all currently idle connections. Connections currently checked - # out are unaffected. + # out are unaffected. The pool will stop maintaining its minimum size until + # it is reactivated. def flush! reap flush(-1) + + # Stop maintaining the minimum size until reactivated + @activated = false + end + + # Ensure that the pool contains at least the configured minimum number of + # connections. + def prepopulate + return if self.discarded? + + # We don't want to start prepopulating until we know the pool is wanted, + # so we can avoid maintaining full pools in one-off scripts etc. + return unless @activated + + if @connections.size < @min_size + while new_conn = try_to_checkout_new_connection { @connections.size < @min_size } + checkin(new_conn) + end + end end def num_waiting_in_queue # :nodoc: @@ -1080,6 +1110,9 @@ def remove_connection_from_thread_cache(conn, owner_thread = conn.owner) # If the pool is not at a @max_size limit, establish new connection. Connecting # to the DB is done outside main synchronized section. + # + # If a block is supplied, it is an additional constraint (checked while holding the + # pool lock) on whether a new connection should be established. #-- # Implementation constraint: a newly established connection returned by this # method must be in the +.leased+ state. @@ -1088,7 +1121,7 @@ def try_to_checkout_new_connection # and increment @now_connecting, to prevent overstepping this pool's @max_size # constraint do_checkout = synchronize do - if @threads_blocking_new_connections.zero? && (@connections.size + @now_connecting) < @max_size + if @threads_blocking_new_connections.zero? && (@connections.size + @now_connecting) < @max_size && (!block_given? || yield) @now_connecting += 1 end end diff --git a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool/reaper.rb b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool/reaper.rb index 44fa0b1b84793..e91b79bf79091 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool/reaper.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool/reaper.rb @@ -54,6 +54,7 @@ def spawn_thread(frequency) @pools[frequency].each do |p| p.reap p.flush + p.prepopulate rescue WeakRef::RefError end diff --git a/activerecord/test/cases/connection_pool_test.rb b/activerecord/test/cases/connection_pool_test.rb index bd6d678ea6e41..2fe513ef58f56 100644 --- a/activerecord/test/cases/connection_pool_test.rb +++ b/activerecord/test/cases/connection_pool_test.rb @@ -282,6 +282,16 @@ def test_idle_timeout_configuration assert_equal 0, @pool.connections.length end + def test_min_size_configuration + @pool.disconnect! + + @pool = new_pool_with_options(min_size: 1) + + @pool.activate + @pool.prepopulate + assert_equal 1, @pool.connections.length + end + def test_idle_timeout_configuration_with_min_size @pool.disconnect! diff --git a/activerecord/test/cases/reaper_test.rb b/activerecord/test/cases/reaper_test.rb index 213ec170ea4ea..c7ebdfa7f4b0b 100644 --- a/activerecord/test/cases/reaper_test.rb +++ b/activerecord/test/cases/reaper_test.rb @@ -29,6 +29,9 @@ def discard! def discarded? @discarded end + + def prepopulate + end end # A reaper with nil time should never reap connections From 0c71e4f82b1996d7af5480631460a939edd28347 Mon Sep 17 00:00:00 2001 From: Matthew Draper Date: Sun, 29 Sep 2024 19:57:24 -0400 Subject: [PATCH 0535/1075] Automatically activate pool when a checkout occurs in a second context --- .../abstract/connection_pool.rb | 7 ++- .../test/cases/connection_pool_test.rb | 52 +++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb index 1a27a7e4f52ab..cb677c9d0c333 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb @@ -286,6 +286,7 @@ def initialize(pool_config) @schema_cache = nil @activated = false + @original_context = ActiveSupport::IsolatedExecutionState.context @reaper = Reaper.new(self, db_config.reaping_frequency) @reaper.run @@ -725,7 +726,7 @@ def flush(minimum_idle = @idle_timeout) # Disconnect all currently idle connections. Connections currently checked # out are unaffected. The pool will stop maintaining its minimum size until - # it is reactivated. + # it is reactivated (such as by a subsequent checkout). def flush! reap flush(-1) @@ -1122,6 +1123,10 @@ def try_to_checkout_new_connection # constraint do_checkout = synchronize do if @threads_blocking_new_connections.zero? && (@connections.size + @now_connecting) < @max_size && (!block_given? || yield) + if @connections.size > 0 || @original_context != ActiveSupport::IsolatedExecutionState.context + @activated = true + end + @now_connecting += 1 end end diff --git a/activerecord/test/cases/connection_pool_test.rb b/activerecord/test/cases/connection_pool_test.rb index 2fe513ef58f56..fc6f9bfcdd15c 100644 --- a/activerecord/test/cases/connection_pool_test.rb +++ b/activerecord/test/cases/connection_pool_test.rb @@ -384,6 +384,58 @@ def idle_conn.seconds_idle @pool.checkin active_conn end + def test_automatic_activation + assert_not_predicate @pool, :activated? + + # Checkout on root thread does not activate the pool + own_conn = @pool.checkout + + assert_not_predicate @pool, :activated? + + new_thread do + # Checkout on new thread activates the pool + conn = @pool.checkout + + assert_predicate @pool, :activated? + @pool.checkin conn + end.join + + @pool.checkin own_conn + + # Pool remains activated + assert_predicate @pool, :activated? + + # flush! deactivates the pool + @pool.flush! + + assert_not_predicate @pool, :activated? + + new_thread do + @pool.checkin(@pool.checkout) + end.join + + # Off-thread checkout re-activates + assert_predicate @pool, :activated? + end + + def test_prepopulate + pool = new_pool_with_options(min_size: 3, max_size: 3, async: false) + + assert_equal 0, pool.connections.length + assert_not_predicate pool, :activated? + + # Pool is not activated, so prepopulate is a no-op + pool.prepopulate + + assert_equal 0, pool.connections.length + pool.activate + + assert_predicate pool, :activated? + pool.prepopulate + + assert_equal 3, pool.connections.length + end + def test_remove_connection conn = @pool.checkout assert_predicate conn, :in_use? From 28638775db153a25230f95929b7760e55db42930 Mon Sep 17 00:00:00 2001 From: Matthew Draper Date: Tue, 1 Oct 2024 02:10:11 -0700 Subject: [PATCH 0536/1075] Preconnect connections that are idle in the pool --- .../abstract/connection_pool.rb | 14 ++++++++++++++ .../abstract/connection_pool/reaper.rb | 1 + .../connection_adapters/abstract_adapter.rb | 10 ++++++++++ .../test/cases/connection_pool_test.rb | 18 ++++++++++++++++++ activerecord/test/cases/reaper_test.rb | 3 +++ 5 files changed, 46 insertions(+) diff --git a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb index cb677c9d0c333..b6a91b447aecb 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb @@ -751,6 +751,20 @@ def prepopulate end end + # Preconnect all connections in the pool. This saves pool users from + # having to wait for a connection to be established when first using it + # after checkout. + def preconnect + sequential_maintenance -> c { (!c.connected? || !c.verified?) && c.allow_preconnect } do |conn| + conn.connect! + rescue + # Wholesale rescue: there's nothing we can do but move on. The + # connection will go back to the pool, and the next consumer will + # presumably try to connect again -- which will either work, or + # fail and they'll be able to report the exception. + end + end + def num_waiting_in_queue # :nodoc: @available.num_waiting end diff --git a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool/reaper.rb b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool/reaper.rb index e91b79bf79091..6d0fa60e2a6a2 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool/reaper.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool/reaper.rb @@ -55,6 +55,7 @@ def spawn_thread(frequency) p.reap p.flush p.prepopulate + p.preconnect rescue WeakRef::RefError end diff --git a/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb b/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb index 3a5f950afa601..e348a69638307 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb @@ -42,6 +42,7 @@ class AbstractAdapter attr_reader :pool attr_reader :visitor, :owner, :logger, :lock + attr_accessor :allow_preconnect alias :in_use? :owner def pool=(value) @@ -152,6 +153,7 @@ def initialize(config_or_deprecated_connection, deprecated_logger = nil, depreca @owner = nil @pool = ActiveRecord::ConnectionAdapters::NullPool.new @idle_since = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @allow_preconnect = true @visitor = arel_visitor @statements = build_statement_pool self.lock_thread = nil @@ -672,12 +674,15 @@ def reconnect!(restore_transactions: false) deadline = retry_deadline && Process.clock_gettime(Process::CLOCK_MONOTONIC) + retry_deadline @lock.synchronize do + @allow_preconnect = false + reconnect enable_lazy_transactions! @raw_connection_dirty = false @last_activity = Process.clock_gettime(Process::CLOCK_MONOTONIC) @verified = true + @allow_preconnect = true reset_transaction(restore: restore_transactions) do clear_cache!(new_connection: true) @@ -773,6 +778,7 @@ def verify! attempt_configure_connection @last_activity = Process.clock_gettime(Process::CLOCK_MONOTONIC) @verified = true + @allow_preconnect = true return end @@ -793,6 +799,10 @@ def clean! # :nodoc: @verified = nil end + def verified? # :nodoc: + @verified + end + # Provides access to the underlying database driver for this adapter. For # example, this method returns a Mysql2::Client object in case of Mysql2Adapter, # and a PG::Connection object in case of PostgreSQLAdapter. diff --git a/activerecord/test/cases/connection_pool_test.rb b/activerecord/test/cases/connection_pool_test.rb index fc6f9bfcdd15c..e049beeb485ef 100644 --- a/activerecord/test/cases/connection_pool_test.rb +++ b/activerecord/test/cases/connection_pool_test.rb @@ -436,6 +436,24 @@ def test_prepopulate assert_equal 3, pool.connections.length end + def test_preconnect + pool = new_pool_with_options(min_size: 3, max_size: 3, async: false) + pool.activate + pool.prepopulate + + assert_equal 3, pool.connections.length + pool.connections.each do |conn| + assert_not_predicate conn, :connected? + end + + pool.preconnect + + assert_equal 3, pool.connections.length + pool.connections.each do |conn| + assert_predicate conn, :connected? + end + end + def test_remove_connection conn = @pool.checkout assert_predicate conn, :in_use? diff --git a/activerecord/test/cases/reaper_test.rb b/activerecord/test/cases/reaper_test.rb index c7ebdfa7f4b0b..1e850021c4cc5 100644 --- a/activerecord/test/cases/reaper_test.rb +++ b/activerecord/test/cases/reaper_test.rb @@ -32,6 +32,9 @@ def discarded? def prepopulate end + + def preconnect + end end # A reaper with nil time should never reap connections From f864afac17139c1c37dd6af40afe3b28e313f4c2 Mon Sep 17 00:00:00 2001 From: Matthew Draper Date: Tue, 1 Oct 2024 02:19:46 -0700 Subject: [PATCH 0537/1075] Application-layer database keepalive Optionally ensure the full database connection chain sees regular query traffic (without affecting our internal idle counters). --- .../abstract/connection_pool.rb | 20 +++- .../abstract/connection_pool/reaper.rb | 1 + .../connection_adapters/abstract_adapter.rb | 1 + .../database_configurations/hash_config.rb | 5 + .../test/cases/connection_pool_test.rb | 109 ++++++++++++++++++ activerecord/test/cases/reaper_test.rb | 3 + 6 files changed, 138 insertions(+), 1 deletion(-) diff --git a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb index b6a91b447aecb..725f9ac75feb9 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb @@ -225,7 +225,7 @@ def install_executor_hooks(executor = ActiveSupport::Executor) include ConnectionAdapters::AbstractPool attr_accessor :automatic_reconnect, :checkout_timeout - attr_reader :db_config, :max_size, :min_size, :reaper, :pool_config, :async_executor, :role, :shard + attr_reader :db_config, :max_size, :min_size, :keepalive, :reaper, :pool_config, :async_executor, :role, :shard alias :size :max_size delegate :schema_reflection, :server_version, to: :pool_config @@ -248,6 +248,7 @@ def initialize(pool_config) @idle_timeout = db_config.idle_timeout @max_size = db_config.pool @min_size = db_config.min_size + @keepalive = db_config.keepalive # This variable tracks the cache of threads mapped to reserved connections, with the # sole purpose of speeding up the +connection+ method. It is not the authoritative @@ -765,6 +766,23 @@ def preconnect end end + # Prod any connections that have been idle for longer than the configured + # keepalive time. This will incidentally verify the connection is still + # alive, but the main purpose is to show the server (and any intermediate + # network hops) that we're still here and using the connection. + def keep_alive(threshold = @keepalive) + return if threshold.nil? + + sequential_maintenance -> c { (c.seconds_since_last_activity || 0) > threshold } do |conn| + # conn.active? will cause some amount of network activity, which is all + # we need to provide a keepalive signal. + # + # If it returns false, the connection is already broken; disconnect, + # so it can be found and repaired. + conn.disconnect! unless conn.active? + end + end + def num_waiting_in_queue # :nodoc: @available.num_waiting end diff --git a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool/reaper.rb b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool/reaper.rb index 6d0fa60e2a6a2..8e741225235d9 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool/reaper.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool/reaper.rb @@ -55,6 +55,7 @@ def spawn_thread(frequency) p.reap p.flush p.prepopulate + p.keep_alive p.preconnect rescue WeakRef::RefError end diff --git a/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb b/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb index e348a69638307..6a9bba9eaf9b7 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb @@ -786,6 +786,7 @@ def verify! end end + @last_activity = Process.clock_gettime(Process::CLOCK_MONOTONIC) @verified = true end diff --git a/activerecord/lib/active_record/database_configurations/hash_config.rb b/activerecord/lib/active_record/database_configurations/hash_config.rb index 4b18ecdf659c0..508d50ced4950 100644 --- a/activerecord/lib/active_record/database_configurations/hash_config.rb +++ b/activerecord/lib/active_record/database_configurations/hash_config.rb @@ -108,6 +108,11 @@ def idle_timeout timeout if timeout > 0 end + def keepalive + keepalive = (configuration_hash[:keepalive] || 600).to_f + keepalive if keepalive > 0 + end + def adapter configuration_hash[:adapter]&.to_s end diff --git a/activerecord/test/cases/connection_pool_test.rb b/activerecord/test/cases/connection_pool_test.rb index e049beeb485ef..44d0784cd9715 100644 --- a/activerecord/test/cases/connection_pool_test.rb +++ b/activerecord/test/cases/connection_pool_test.rb @@ -454,6 +454,115 @@ def test_preconnect end end + def test_keepalive + pool = new_pool_with_options(keepalive: 100, async: false) + conn = pool.checkout + conn.connect! + pool.checkin conn + + assert_operator conn.seconds_since_last_activity, :<, 5 + + conn.instance_variable_set(:@last_activity, Process.clock_gettime(Process::CLOCK_MONOTONIC) - 50) + + assert_in_epsilon 50, conn.seconds_since_last_activity, 10 + + # we're currently below the threshold, so this is a no-op + pool.keep_alive + + # still about the same age + assert_in_epsilon 50, conn.seconds_since_last_activity, 10 + + conn.instance_variable_set(:@last_activity, Process.clock_gettime(Process::CLOCK_MONOTONIC) - 200) + + pool.keep_alive + + # keep-alive query occurred, so our activity time has reset + assert_operator conn.seconds_since_last_activity, :<, 5 + end + + def test_keepalive_notices_problems + pool = new_pool_with_options(keepalive: 100, async: false) + conn = pool.checkout + conn.connect! + pool.checkin conn + + original_broken_connection = conn.instance_variable_get(:@raw_connection) + + assert_predicate conn, :connected? + + # This is a definite API violation -- several nearby tests overstep by + # poking around unowned connections' bookkeeping, but actually querying + # (as #remote_disconnect does) without owning the connection is extreme. + # In practice, though, it should be fine: the pool belongs to this test, + # so the only other thread we could be competing with is the pool's + # reaper. + remote_disconnect conn + + assert_same original_broken_connection, conn.instance_variable_get(:@raw_connection) + + # we're below the threshold; no keep-alive occurs + pool.keep_alive + + # connection is still [unknowingly] broken + assert_same original_broken_connection, conn.instance_variable_get(:@raw_connection) + assert_predicate conn, :connected? + assert_not_predicate conn, :active? + + conn.instance_variable_set(:@last_activity, Process.clock_gettime(Process::CLOCK_MONOTONIC) - 200) + pool.keep_alive + + # keep-alive noticed the problem and disconnected + assert_not_predicate conn, :connected? + + # .. so now preconnect can repair the connection + pool.preconnect + + assert_not_same original_broken_connection, conn.instance_variable_get(:@raw_connection) + assert_predicate conn, :connected? + assert_predicate conn, :active? + end + + def test_idle_through_keepalive + pool = new_pool_with_options(keepalive: 0.1, idle_timeout: 0.5, async: false) + conn = pool.checkout + conn.connect! + pool.checkin conn + + assert_predicate conn, :connected? + assert_operator conn.seconds_since_last_activity, :<, 0.1 + assert_operator conn.seconds_idle, :<, 0.1 + + # This test is about the interaction between multiple "last use" + # timers, so manual fudging is a bit too intimate / relies on + # knowledge of the implementation. So instead, we have to live + # with a bit of sleeping (and hope we don't lose any races). + + sleep 0.2 + + assert_operator conn.seconds_since_last_activity, :>, 0.1 + assert_operator conn.seconds_idle, :>, 0.1 + assert_operator conn.seconds_idle, :<, 0.5 + + pool.keep_alive # sends a keep-alive query + pool.flush # no-op + + assert_predicate conn, :connected? + assert_operator conn.seconds_since_last_activity, :<, 0.1 + assert_operator conn.seconds_idle, :>, 0.1 + assert_operator conn.seconds_idle, :<, 0.5 + + sleep 0.4 + + assert_predicate conn, :connected? + assert_operator conn.seconds_since_last_activity, :>, 0.1 + assert_operator conn.seconds_idle, :>, 0.5 + + pool.keep_alive # sends another query, though it doesn't matter + pool.flush # drops the idle connection + + assert_not_predicate conn, :connected? + end + def test_remove_connection conn = @pool.checkout assert_predicate conn, :in_use? diff --git a/activerecord/test/cases/reaper_test.rb b/activerecord/test/cases/reaper_test.rb index 1e850021c4cc5..a5fb8e9c545dc 100644 --- a/activerecord/test/cases/reaper_test.rb +++ b/activerecord/test/cases/reaper_test.rb @@ -35,6 +35,9 @@ def prepopulate def preconnect end + + def keep_alive + end end # A reaper with nil time should never reap connections From c8e0310687d75f6825e89b80744d3f71234833d9 Mon Sep 17 00:00:00 2001 From: Matthew Draper Date: Tue, 1 Oct 2024 02:24:01 -0700 Subject: [PATCH 0538/1075] Maximum age for database connections Avoid using database connections that were originally established over the configured duration ago. This can be helpful to provide smooth failover between connection proxies. --- .../abstract/connection_pool.rb | 13 +++++++++- .../abstract/connection_pool/reaper.rb | 1 + .../connection_adapters/abstract_adapter.rb | 14 ++++++++++- .../database_configurations/hash_config.rb | 9 +++++++ .../test/cases/connection_pool_test.rb | 24 +++++++++++++++++++ activerecord/test/cases/reaper_test.rb | 3 +++ 6 files changed, 62 insertions(+), 2 deletions(-) diff --git a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb index 725f9ac75feb9..b982ac068c047 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb @@ -225,7 +225,7 @@ def install_executor_hooks(executor = ActiveSupport::Executor) include ConnectionAdapters::AbstractPool attr_accessor :automatic_reconnect, :checkout_timeout - attr_reader :db_config, :max_size, :min_size, :keepalive, :reaper, :pool_config, :async_executor, :role, :shard + attr_reader :db_config, :max_size, :min_size, :max_age, :keepalive, :reaper, :pool_config, :async_executor, :role, :shard alias :size :max_size delegate :schema_reflection, :server_version, to: :pool_config @@ -248,6 +248,7 @@ def initialize(pool_config) @idle_timeout = db_config.idle_timeout @max_size = db_config.pool @min_size = db_config.min_size + @max_age = db_config.max_age @keepalive = db_config.keepalive # This variable tracks the cache of threads mapped to reserved connections, with the @@ -752,6 +753,16 @@ def prepopulate end end + def retire_old_connections(max_age = @max_age) + max_age ||= Float::INFINITY + + sequential_maintenance -> c { c.connection_age&.>= max_age } do |conn| + # Disconnect, then return the adapter to the pool. Preconnect will + # handle the rest. + conn.disconnect! + end + end + # Preconnect all connections in the pool. This saves pool users from # having to wait for a connection to be established when first using it # after checkout. diff --git a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool/reaper.rb b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool/reaper.rb index 8e741225235d9..d6d96ffe1e955 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool/reaper.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool/reaper.rb @@ -55,6 +55,7 @@ def spawn_thread(frequency) p.reap p.flush p.prepopulate + p.retire_old_connections p.keep_alive p.preconnect rescue WeakRef::RefError diff --git a/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb b/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb index 6a9bba9eaf9b7..3c497cb98fbfa 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb @@ -128,6 +128,7 @@ def initialize(config_or_deprecated_connection, deprecated_logger = nil, depreca @raw_connection = nil @unconfigured_connection = nil + @connected_since = nil if config_or_deprecated_connection.is_a?(Hash) @config = config_or_deprecated_connection.symbolize_keys @@ -140,6 +141,7 @@ def initialize(config_or_deprecated_connection, deprecated_logger = nil, depreca # Soft-deprecated for now; we'll probably warn in future. @unconfigured_connection = config_or_deprecated_connection + @connected_since = Process.clock_gettime(Process::CLOCK_MONOTONIC) @logger = deprecated_logger || ActiveRecord::Base.logger if deprecated_config @config = (deprecated_config || {}).symbolize_keys @@ -347,6 +349,15 @@ def seconds_since_last_activity # :nodoc: end end + # Seconds since this connection was established. nil if not + # connected; infinity if the connection has been explicitly + # retired. + def connection_age # :nodoc: + if @raw_connection && @connected_since + Process.clock_gettime(Process::CLOCK_MONOTONIC) - @connected_since + end + end + def unprepared_statement cache = prepared_statements_disabled_cache.add?(object_id) if @prepared_statements yield @@ -680,7 +691,7 @@ def reconnect!(restore_transactions: false) enable_lazy_transactions! @raw_connection_dirty = false - @last_activity = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @last_activity = @connected_since = Process.clock_gettime(Process::CLOCK_MONOTONIC) @verified = true @allow_preconnect = true @@ -715,6 +726,7 @@ def disconnect! clear_cache!(new_connection: true) reset_transaction @raw_connection_dirty = false + @connected_since = nil end end diff --git a/activerecord/lib/active_record/database_configurations/hash_config.rb b/activerecord/lib/active_record/database_configurations/hash_config.rb index 508d50ced4950..75eea6e593abe 100644 --- a/activerecord/lib/active_record/database_configurations/hash_config.rb +++ b/activerecord/lib/active_record/database_configurations/hash_config.rb @@ -85,6 +85,15 @@ def max_threads (configuration_hash[:max_threads] || pool).to_i end + def max_age + v = configuration_hash[:max_age]&.to_i + if v && v > 0 + v + else + Float::INFINITY + end + end + def query_cache configuration_hash[:query_cache] end diff --git a/activerecord/test/cases/connection_pool_test.rb b/activerecord/test/cases/connection_pool_test.rb index 44d0784cd9715..ff2794a091831 100644 --- a/activerecord/test/cases/connection_pool_test.rb +++ b/activerecord/test/cases/connection_pool_test.rb @@ -454,6 +454,30 @@ def test_preconnect end end + def test_max_age + pool = new_pool_with_options(max_age: 10, async: false) + + conn = pool.checkout + + assert_not_predicate conn, :connected? + assert_nil conn.connection_age + + conn.connect! + + assert_predicate conn, :connected? + assert_operator conn.connection_age, :>=, 0 + assert_operator conn.connection_age, :<, 1 + + conn.instance_variable_set(:@connected_since, Process.clock_gettime(Process::CLOCK_MONOTONIC) - 11) + + assert_operator conn.connection_age, :>, 10 + + pool.checkin conn + pool.retire_old_connections + + assert_not_predicate conn, :connected? + end + def test_keepalive pool = new_pool_with_options(keepalive: 100, async: false) conn = pool.checkout diff --git a/activerecord/test/cases/reaper_test.rb b/activerecord/test/cases/reaper_test.rb index a5fb8e9c545dc..8ae3dd2c28776 100644 --- a/activerecord/test/cases/reaper_test.rb +++ b/activerecord/test/cases/reaper_test.rb @@ -38,6 +38,9 @@ def preconnect def keep_alive end + + def retire_old_connections + end end # A reaper with nil time should never reap connections From ab69d584fa7dbcb0bdeee8a52b55ceb5e42f531c Mon Sep 17 00:00:00 2001 From: Matthew Draper Date: Tue, 1 Oct 2024 02:28:11 -0700 Subject: [PATCH 0539/1075] Allow the entire connection pool to be recycled on demand This is ideal where the caller knows that a database server/proxy failover is occurring (meaning that previously-established connections are now potentially pointing to the wrong place, and new fresh connections will go to the right one); where such signalling is possible, it can be used in place of an onerously low / "preventative" max_age. --- .../abstract/connection_pool.rb | 15 ++++++++++++ .../connection_adapters/abstract_adapter.rb | 6 +++++ .../test/cases/connection_pool_test.rb | 24 +++++++++++++++++++ 3 files changed, 45 insertions(+) diff --git a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb index b982ac068c047..370157368006f 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb @@ -794,6 +794,21 @@ def keep_alive(threshold = @keepalive) end end + # Immediately mark all current connections as due for replacement, + # equivalent to them having reached +max_age+ -- even if there is + # no +max_age+ configured. + def recycle! + synchronize do + return if self.discarded? + + @connections.each do |conn| + conn.force_retirement + end + end + + retire_old_connections + end + def num_waiting_in_queue # :nodoc: @available.num_waiting end diff --git a/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb b/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb index 3c497cb98fbfa..7686575b21cf8 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb @@ -358,6 +358,12 @@ def connection_age # :nodoc: end end + # Mark the connection as needing to be retired, as if the age has + # exceeded the maximum allowed. + def force_retirement # :nodoc: + @connected_since &&= -Float::INFINITY + end + def unprepared_statement cache = prepared_statements_disabled_cache.add?(object_id) if @prepared_statements yield diff --git a/activerecord/test/cases/connection_pool_test.rb b/activerecord/test/cases/connection_pool_test.rb index ff2794a091831..6974eebb6280b 100644 --- a/activerecord/test/cases/connection_pool_test.rb +++ b/activerecord/test/cases/connection_pool_test.rb @@ -478,6 +478,30 @@ def test_max_age assert_not_predicate conn, :connected? end + def test_explicit_retirement + pool = new_pool_with_options(max_age: nil, async: false) + + conn = pool.checkout + + assert_not_predicate conn, :connected? + assert_nil conn.connection_age + + conn.connect! + + assert_predicate conn, :connected? + assert_operator conn.connection_age, :>=, 0 + assert_operator conn.connection_age, :<, 1 + + pool.recycle! + + assert_equal Float::INFINITY, conn.connection_age + + pool.checkin conn + pool.retire_old_connections + + assert_not_predicate conn, :connected? + end + def test_keepalive pool = new_pool_with_options(keepalive: 100, async: false) conn = pool.checkout From da61ebadc9d8d8f71cf40c0e40c70f7698ecbcfb Mon Sep 17 00:00:00 2001 From: Matthew Draper Date: Tue, 1 Oct 2024 02:34:06 -0700 Subject: [PATCH 0540/1075] Resolve the ref once at the start of the block It's less elegant, but just seems easier to reason about. --- .../connection_adapters/abstract/connection_pool/reaper.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool/reaper.rb b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool/reaper.rb index d6d96ffe1e955..7b890f9a04b4d 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool/reaper.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool/reaper.rb @@ -51,7 +51,9 @@ def spawn_thread(frequency) pool.weakref_alive? && !pool.discarded? end - @pools[frequency].each do |p| + @pools[frequency].each do |ref| + p = ref.__getobj__ + p.reap p.flush p.prepopulate From 8daee0ed2cff3e03dd0c6126beb126f899e087ee Mon Sep 17 00:00:00 2001 From: Matthew Draper Date: Tue, 1 Oct 2024 02:34:57 -0700 Subject: [PATCH 0541/1075] Update Reaper to reflect its broader responsibilities --- .../abstract/connection_pool/reaper.rb | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool/reaper.rb b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool/reaper.rb index 7b890f9a04b4d..0a72129833ef7 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool/reaper.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool/reaper.rb @@ -7,12 +7,29 @@ module ConnectionAdapters class ConnectionPool # = Active Record Connection Pool \Reaper # - # Every +frequency+ seconds, the reaper will call +reap+ and +flush+ on - # +pool+. A reaper instantiated with a zero frequency will never reap - # the connection pool. + # The reaper is a singleton that exists in the background of the process + # and is responsible for general maintenance of all the connection pools. # - # Configure the frequency by setting +reaping_frequency+ in your database - # YAML file (default 60 seconds). + # It will reclaim connections that are leased to now-dead threads, + # ensuring that a bad thread can't leak a pool slot forever. By definition, + # this involves touching currently-leased connections, but that is safe + # because the owning thread is known to be dead. + # + # Beyond that, it manages the health of available / unleased connections: + # * retiring connections that have been idle[1] for too long + # * creating occasional activity on inactive[1] connections + # * keeping the pool prepopulated up to its minimum size + # * proactively connecting to the target database from any pooled + # connections that had lazily deferred that step + # * resetting or replacing connections that are known to be broken + # + # + # [1]: "idle" and "inactive" here distinguish between connections that + # have not been requested by the application in a while (idle) and those + # that have not spoken to their remote server in a while (inactive). The + # former is a desirable opportunity to reduce our connection count + # (`idle_timeout`); the latter is a risk that the server or a firewall may + # drop a connection we still anticipate using (avoided by `keepalive`). class Reaper attr_reader :pool, :frequency From e45e8be0c4999f0ae0d57176da8c70541be67a84 Mon Sep 17 00:00:00 2001 From: ChaelCodes Date: Tue, 14 Jan 2025 15:30:00 +0000 Subject: [PATCH 0542/1075] Deprecate pool config value in favor of max_connections and min_connections Co-authored-by: Matthew Draper --- .../abstract/connection_pool.rb | 39 ++++---- .../database_config.rb | 6 +- .../database_configurations/hash_config.rb | 16 ++-- ...rge_and_resolve_default_url_config_test.rb | 34 +++---- .../test/cases/connection_pool_test.rb | 26 +++--- .../hash_config_test.rb | 88 ++++++++++++++++--- .../database_configurations/resolver_test.rb | 10 +-- .../test/cases/pooled_connections_test.rb | 10 +-- .../templates/config/databases/mysql.yml.tt | 2 +- .../config/databases/postgresql.yml.tt | 2 +- .../templates/config/databases/sqlite3.yml.tt | 2 +- .../templates/config/databases/trilogy.yml.tt | 2 +- railties/test/application/dbconsole_test.rb | 4 +- .../test/application/multi_db_rake_test.rb | 2 +- railties/test/application/rake/dbs_test.rb | 4 +- railties/test/commands/dbconsole_test.rb | 38 ++++---- railties/test/isolation/abstract_unit.rb | 12 +-- 17 files changed, 186 insertions(+), 111 deletions(-) diff --git a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb index 370157368006f..ebdfacf3d2d19 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb @@ -105,13 +105,18 @@ def pool_transaction_isolation_level=(isolation_level) # There are several connection-pooling-related options that you can add to # your database connection configuration: # - # * +pool+: maximum number of connections the pool may manage (default 5). - # * +idle_timeout+: number of seconds that a connection will be kept - # unused in the pool before it is automatically disconnected (default - # 300 seconds). Set this to zero to keep connections forever. # * +checkout_timeout+: number of seconds to wait for a connection to # become available before giving up and raising a timeout error (default # 5 seconds). + # * +idle_timeout+: number of seconds that a connection will be kept + # unused in the pool before it is automatically disconnected (default + # 300 seconds). Set this to zero to keep connections forever. + # * +keepalive+: number of seconds between keepalive checks if the + # connection has been idle (default 600 seconds). + # * +max_age+: number of seconds the pool will allow the connection to + # exist before retiring it at next checkin. (default Float::INFINITY). + # * +max_connections+: maximum number of connections the pool may manage (default 5). + # * +min_connections+: minimum number of connections the pool will open and maintain (default 0). # #-- # Synchronization policy: @@ -225,8 +230,8 @@ def install_executor_hooks(executor = ActiveSupport::Executor) include ConnectionAdapters::AbstractPool attr_accessor :automatic_reconnect, :checkout_timeout - attr_reader :db_config, :max_size, :min_size, :max_age, :keepalive, :reaper, :pool_config, :async_executor, :role, :shard - alias :size :max_size + attr_reader :db_config, :max_connections, :min_connections, :max_age, :keepalive, :reaper, :pool_config, :async_executor, :role, :shard + alias :size :max_connections delegate :schema_reflection, :server_version, to: :pool_config @@ -246,8 +251,8 @@ def initialize(pool_config) @checkout_timeout = db_config.checkout_timeout @idle_timeout = db_config.idle_timeout - @max_size = db_config.pool - @min_size = db_config.min_size + @max_connections = db_config.max_connections + @min_connections = db_config.min_connections @max_age = db_config.max_age @keepalive = db_config.keepalive @@ -645,7 +650,7 @@ def remove(conn) @available.delete conn # @available.any_waiting? => true means that prior to removing this - # conn, the pool was at its max size (@connections.size == @max_size). + # conn, the pool was at its max size (@connections.size == @max_connections). # This would mean that any threads stuck waiting in the queue wouldn't # know they could checkout_new_connection, so let's do it for them. # Because condition-wait loop is encapsulated in the Queue class @@ -660,7 +665,7 @@ def remove(conn) # would like not to hold the main mutex while checking out new connections. # Thus there is some chance that needs_new_connection information is now # stale, we can live with that (bulk_make_new_connections will make - # sure not to exceed the pool's @max_size limit). + # sure not to exceed the pool's @max_connections limit). bulk_make_new_connections(1) if needs_new_connection end @@ -704,7 +709,7 @@ def flush(minimum_idle = @idle_timeout) # everything idles_to_retain = if minimum_idle > 0 - @min_size - (@connections.size - idle_connections.size) + @min_connections - (@connections.size - idle_connections.size) else 0 end @@ -746,8 +751,8 @@ def prepopulate # so we can avoid maintaining full pools in one-off scripts etc. return unless @activated - if @connections.size < @min_size - while new_conn = try_to_checkout_new_connection { @connections.size < @min_size } + if @connections.size < @min_connections + while new_conn = try_to_checkout_new_connection { @connections.size < @min_connections } checkin(new_conn) end end @@ -982,7 +987,7 @@ def remove_from_maintenance(conn) # this is unfortunately not concurrent def bulk_make_new_connections(num_new_conns_needed) num_new_conns_needed.times do - # try_to_checkout_new_connection will not exceed pool's @max_size limit + # try_to_checkout_new_connection will not exceed pool's @max_connections limit if new_conn = try_to_checkout_new_connection # make the new_conn available to the starving threads stuck @available Queue checkin(new_conn) @@ -1167,7 +1172,7 @@ def remove_connection_from_thread_cache(conn, owner_thread = conn.owner) end alias_method :release, :remove_connection_from_thread_cache - # If the pool is not at a @max_size limit, establish new connection. Connecting + # If the pool is not at a @max_connections limit, establish new connection. Connecting # to the DB is done outside main synchronized section. # # If a block is supplied, it is an additional constraint (checked while holding the @@ -1177,10 +1182,10 @@ def remove_connection_from_thread_cache(conn, owner_thread = conn.owner) # method must be in the +.leased+ state. def try_to_checkout_new_connection # first in synchronized section check if establishing new conns is allowed - # and increment @now_connecting, to prevent overstepping this pool's @max_size + # and increment @now_connecting, to prevent overstepping this pool's @max_connections # constraint do_checkout = synchronize do - if @threads_blocking_new_connections.zero? && (@connections.size + @now_connecting) < @max_size && (!block_given? || yield) + if @threads_blocking_new_connections.zero? && (@connections.size + @now_connecting) < @max_connections && (!block_given? || yield) if @connections.size > 0 || @original_context != ActiveSupport::IsolatedExecutionState.context @activated = true end diff --git a/activerecord/lib/active_record/database_configurations/database_config.rb b/activerecord/lib/active_record/database_configurations/database_config.rb index d6f0958117486..e3b5e0d6747ed 100644 --- a/activerecord/lib/active_record/database_configurations/database_config.rb +++ b/activerecord/lib/active_record/database_configurations/database_config.rb @@ -48,7 +48,11 @@ def adapter raise NotImplementedError end - def pool + def min_connections + raise NotImplementedError + end + + def max_connections raise NotImplementedError end diff --git a/activerecord/lib/active_record/database_configurations/hash_config.rb b/activerecord/lib/active_record/database_configurations/hash_config.rb index 75eea6e593abe..46cba9f746420 100644 --- a/activerecord/lib/active_record/database_configurations/hash_config.rb +++ b/activerecord/lib/active_record/database_configurations/hash_config.rb @@ -38,6 +38,9 @@ class HashConfig < DatabaseConfig def initialize(env_name, name, configuration_hash) super(env_name, name) @configuration_hash = configuration_hash.symbolize_keys.freeze + ActiveRecord.deprecator.warn(<<~MSG) if @configuration_hash[:pool] + The pool option is deprecated and will be removed in Rails 8.2. Use max_connections instead. + MSG end # Determines whether a database configuration is for a replica / readonly @@ -69,20 +72,23 @@ def _database=(database) # :nodoc: @configuration_hash = configuration_hash.merge(database: database).freeze end - def pool - (configuration_hash[:pool] || 5).to_i + def max_connections + (configuration_hash[:max_connections] || configuration_hash[:pool] || 5).to_i end - def min_size - (configuration_hash[:min_size] || 0).to_i + def min_connections + (configuration_hash[:min_connections] || 0).to_i end + alias :pool :max_connections + deprecate pool: :max_connections, deprecator: ActiveRecord.deprecator + def min_threads (configuration_hash[:min_threads] || 0).to_i end def max_threads - (configuration_hash[:max_threads] || pool).to_i + (configuration_hash[:max_threads] || max_connections).to_i end def max_age diff --git a/activerecord/test/cases/connection_adapters/merge_and_resolve_default_url_config_test.rb b/activerecord/test/cases/connection_adapters/merge_and_resolve_default_url_config_test.rb index f11c1dfaa91b6..17e51615a219f 100644 --- a/activerecord/test/cases/connection_adapters/merge_and_resolve_default_url_config_test.rb +++ b/activerecord/test/cases/connection_adapters/merge_and_resolve_default_url_config_test.rb @@ -323,13 +323,13 @@ def test_no_url_sub_key_with_database_url_doesnt_trample_other_envs def test_merge_no_conflicts_with_database_url ENV["DATABASE_URL"] = "postgres://localhost/foo" - config = { "default_env" => { "adapter" => "abstract", "pool" => "5" } } + config = { "default_env" => { "adapter" => "abstract", "max_connections" => "5" } } actual = resolve_config(config) expected = { adapter: "postgresql", database: "foo", host: "localhost", - pool: "5" + max_connections: "5" } assert_equal expected, actual @@ -338,13 +338,13 @@ def test_merge_no_conflicts_with_database_url def test_merge_conflicts_with_database_url ENV["DATABASE_URL"] = "postgres://localhost/foo" - config = { "default_env" => { "adapter" => "abstract", "database" => "NOT-FOO", "pool" => "5" } } + config = { "default_env" => { "adapter" => "abstract", "database" => "NOT-FOO", "max_connections" => "5" } } actual = resolve_config(config) expected = { adapter: "postgresql", database: "foo", host: "localhost", - pool: "5" + max_connections: "5" } assert_equal expected, actual @@ -353,28 +353,28 @@ def test_merge_conflicts_with_database_url def test_merge_no_conflicts_with_database_url_and_adapter ENV["DATABASE_URL"] = "postgres://localhost/foo" - config = { "default_env" => { "adapter" => "postgresql", "pool" => "5" } } + config = { "default_env" => { "adapter" => "postgresql", "max_connections" => "5" } } actual = resolve_config(config) expected = { adapter: "postgresql", database: "foo", host: "localhost", - pool: "5" + max_connections: "5" } assert_equal expected, actual end - def test_merge_no_conflicts_with_database_url_and_numeric_pool + def test_merge_no_conflicts_with_database_url_and_numeric_max_connections ENV["DATABASE_URL"] = "postgres://localhost/foo" - config = { "default_env" => { "adapter" => "abstract", "pool" => 5 } } + config = { "default_env" => { "adapter" => "abstract", "max_connections" => 5 } } actual = resolve_config(config) expected = { adapter: "postgresql", database: "foo", host: "localhost", - pool: 5 + max_connections: 5 } assert_equal expected, actual @@ -385,25 +385,25 @@ def test_tiered_configs_with_database_url config = { "default_env" => { - "primary" => { "adapter" => "abstract", "pool" => 5 }, - "animals" => { "adapter" => "abstract", "pool" => 5 } + "primary" => { "adapter" => "abstract", "max_connections" => 5 }, + "animals" => { "adapter" => "abstract", "max_connections" => 5 } } } configs = ActiveRecord::DatabaseConfigurations.new(config) actual = configs.configs_for(env_name: "default_env", name: "primary").configuration_hash expected = { - adapter: "postgresql", + adapter: "postgresql", database: "foo", - host: "localhost", - pool: 5 + host: "localhost", + max_connections: 5 } assert_equal expected, actual configs = ActiveRecord::DatabaseConfigurations.new(config) actual = configs.configs_for(env_name: "default_env", name: "animals").configuration_hash - expected = { adapter: "abstract", pool: 5 } + expected = { adapter: "abstract", max_connections: 5 } assert_equal expected, actual end @@ -415,8 +415,8 @@ def test_separate_database_env_vars config = { "default_env" => { - "primary" => { "adapter" => "abstract", "pool" => 5 }, - "animals" => { "adapter" => "abstract", "pool" => 5 } + "primary" => { "adapter" => "abstract", "max_connections" => 5 }, + "animals" => { "adapter" => "abstract", "max_connections" => 5 } } } diff --git a/activerecord/test/cases/connection_pool_test.rb b/activerecord/test/cases/connection_pool_test.rb index 6974eebb6280b..5409544cd46d2 100644 --- a/activerecord/test/cases/connection_pool_test.rb +++ b/activerecord/test/cases/connection_pool_test.rb @@ -149,7 +149,7 @@ def test_full_pool_blocks def test_full_pool_blocking_shares_load_interlock skip_fiber_testing - @pool.instance_variable_set(:@max_size, 1) + @pool.instance_variable_set(:@max_connections, 1) load_interlock_latch = Concurrent::CountDownLatch.new connection_latch = Concurrent::CountDownLatch.new @@ -236,7 +236,7 @@ def test_reap_inactive def test_inactive_are_returned_from_dead_thread ready = Concurrent::CountDownLatch.new - @pool.instance_variable_set(:@max_size, 1) + @pool.instance_variable_set(:@max_connections, 1) child = new_thread do @pool.checkout @@ -282,20 +282,20 @@ def test_idle_timeout_configuration assert_equal 0, @pool.connections.length end - def test_min_size_configuration + def test_min_connections_configuration @pool.disconnect! - @pool = new_pool_with_options(min_size: 1) + @pool = new_pool_with_options(min_connections: 1) @pool.activate @pool.prepopulate assert_equal 1, @pool.connections.length end - def test_idle_timeout_configuration_with_min_size + def test_idle_timeout_configuration_with_min_connections @pool.disconnect! - @pool = new_pool_with_options(idle_timeout: "0.02", min_size: 1) + @pool = new_pool_with_options(idle_timeout: "0.02", min_connections: 1) connections = 2.times.map { @pool.checkout } connections.each { |conn| @pool.checkin(conn) } @@ -419,7 +419,7 @@ def test_automatic_activation end def test_prepopulate - pool = new_pool_with_options(min_size: 3, max_size: 3, async: false) + pool = new_pool_with_options(min_connections: 3, max_connections: 3, async: false) assert_equal 0, pool.connections.length assert_not_predicate pool, :activated? @@ -437,7 +437,7 @@ def test_prepopulate end def test_preconnect - pool = new_pool_with_options(min_size: 3, max_size: 3, async: false) + pool = new_pool_with_options(min_connections: 3, max_connections: 3, async: false) pool.activate pool.prepopulate @@ -686,7 +686,7 @@ def test_checkout_order_is_lifo def test_checkout_fairness skip_fiber_testing - @pool.instance_variable_set(:@max_size, 10) + @pool.instance_variable_set(:@max_connections, 10) expected = (1..@pool.size).to_a.freeze # check out all connections so our threads start out waiting conns = expected.map { @pool.checkout } @@ -733,7 +733,7 @@ def test_checkout_fairness def test_checkout_fairness_by_group skip_fiber_testing - @pool.instance_variable_set(:@max_size, 10) + @pool.instance_variable_set(:@max_connections, 10) # take all the connections conns = (1..10).map { @pool.checkout } mutex = Mutex.new @@ -834,7 +834,7 @@ def test_sequential_maintenance_loop_is_incremental completion_list = [] selection_list = [] - @pool.instance_variable_set(:@max_size, 3) + @pool.instance_variable_set(:@max_connections, 3) @pool.instance_variable_set(:@async_executor, work_collector) 3.times.map { @pool.checkout }.each do |conn| @@ -872,7 +872,7 @@ def test_sequential_maintenance_can_run_inline completion_list = [] selection_list = [] - @pool.instance_variable_set(:@max_size, 3) + @pool.instance_variable_set(:@max_connections, 3) @pool.instance_variable_set(:@async_executor, nil) 3.times.map { @pool.checkout }.each do |conn| @@ -1406,7 +1406,7 @@ def active_connections(pool) end def with_single_connection_pool(**options) - config = @db_config.configuration_hash.merge(pool: 1, **options) + config = @db_config.configuration_hash.merge(max_connections: 1, **options) db_config = ActiveRecord::DatabaseConfigurations::HashConfig.new("arunit", "primary", config) pool_config = ActiveRecord::ConnectionAdapters::PoolConfig.new(ActiveRecord::Base, db_config, :writing, :default) diff --git a/activerecord/test/cases/database_configurations/hash_config_test.rb b/activerecord/test/cases/database_configurations/hash_config_test.rb index 175a41f60327b..b9cb8fd88a147 100644 --- a/activerecord/test/cases/database_configurations/hash_config_test.rb +++ b/activerecord/test/cases/database_configurations/hash_config_test.rb @@ -5,19 +5,79 @@ module ActiveRecord class DatabaseConfigurations class HashConfigTest < ActiveRecord::TestCase - def test_pool_default_when_nil - config = HashConfig.new("default_env", "primary", pool: nil, adapter: "abstract") - assert_equal 5, config.pool + def test_pool_config_raises_deprecation + config = nil + assert_deprecated ActiveRecord.deprecator do + config = HashConfig.new("default_env", "primary", pool: 6, adapter: "abstract") + end + assert_equal 6, config.max_connections + end + + def test_pool_is_deprecated + config = HashConfig.new("default_env", "primary", adapter: "abstract") + assert_deprecated ActiveRecord.deprecator do + assert_equal 5, config.pool + end + end + + def test_max_age_default_when_nil + config = HashConfig.new("default_env", "primary", max_age: nil, adapter: "abstract") + assert_equal Float::INFINITY, config.max_age + end + + def test_max_age_overrides_with_value + config = HashConfig.new("default_env", "primary", max_age: "500", adapter: "abstract") + assert_equal 500, config.max_age + end + + def test_when_no_max_age_uses_default + config = HashConfig.new("default_env", "primary", adapter: "abstract") + assert_equal Float::INFINITY, config.max_age + end + + def test_keepalive_default_when_nil + config = HashConfig.new("default_env", "primary", keepalive: nil, adapter: "abstract") + assert_equal 600, config.keepalive + end + + def test_keepalive_overrides_with_value + config = HashConfig.new("default_env", "primary", keepalive: "500", adapter: "abstract") + assert_equal 500, config.keepalive + end + + def test_when_no_keepalive_uses_default + config = HashConfig.new("default_env", "primary", adapter: "abstract") + assert_equal 600, config.keepalive + end + + def test_max_connections_default_when_nil + config = HashConfig.new("default_env", "primary", max_connections: nil, adapter: "abstract") + assert_equal 5, config.max_connections + end + + def test_max_connections_overrides_with_value + config = HashConfig.new("default_env", "primary", max_connections: "0", adapter: "abstract") + assert_equal 0, config.max_connections + end + + def test_when_no_max_connections_uses_default + config = HashConfig.new("default_env", "primary", adapter: "abstract") + assert_equal 5, config.max_connections + end + + def test_min_connections_default_when_nil + config = HashConfig.new("default_env", "primary", min_connections: nil, adapter: "abstract") + assert_equal 0, config.min_connections end - def test_pool_overrides_with_value - config = HashConfig.new("default_env", "primary", pool: "0", adapter: "abstract") - assert_equal 0, config.pool + def test_min_connections_overrides_with_value + config = HashConfig.new("default_env", "primary", min_connections: "5", adapter: "abstract") + assert_equal 5, config.min_connections end - def test_when_no_pool_uses_default + def test_when_no_min_connections_uses_default config = HashConfig.new("default_env", "primary", adapter: "abstract") - assert_equal 5, config.pool + assert_equal 0, config.min_connections end def test_min_threads_with_value @@ -35,19 +95,19 @@ def test_max_threads_with_value assert_equal 10, config.max_threads end - def test_max_threads_default_uses_pool_default + def test_max_threads_default_uses_max_connections_default config = HashConfig.new("default_env", "primary", adapter: "abstract") - assert_equal 5, config.pool + assert_equal 5, config.max_connections assert_equal 5, config.max_threads end - def test_max_threads_uses_pool_when_set - config = HashConfig.new("default_env", "primary", pool: 1, adapter: "abstract") - assert_equal 1, config.pool + def test_max_threads_uses_max_connections_when_set + config = HashConfig.new("default_env", "primary", max_connections: 1, adapter: "abstract") + assert_equal 1, config.max_connections assert_equal 1, config.max_threads end - def test_max_queue_is_pool_multiplied_by_4 + def test_max_queue_is_max_threads_multiplied_by_4 config = HashConfig.new("default_env", "primary", adapter: "abstract") assert_equal 5, config.max_threads assert_equal config.max_threads * 4, config.max_queue diff --git a/activerecord/test/cases/database_configurations/resolver_test.rb b/activerecord/test/cases/database_configurations/resolver_test.rb index ba65b174d3029..aa1d4d2c6241d 100644 --- a/activerecord/test/cases/database_configurations/resolver_test.rb +++ b/activerecord/test/cases/database_configurations/resolver_test.rb @@ -43,14 +43,14 @@ def test_url_sub_key end def test_url_sub_key_merges_correctly - hash = { "url" => "abstract://foo?encoding=utf8&", "adapter" => "sqlite3", "host" => "bar", "pool" => "3" } + hash = { "url" => "abstract://foo?encoding=utf8&", "adapter" => "sqlite3", "host" => "bar", "max_connections" => "3" } pool_config = resolve_db_config :production, "production" => hash assert_equal({ - adapter: "abstract", - host: "foo", - encoding: "utf8", - pool: "3" + adapter: "abstract", + host: "foo", + encoding: "utf8", + max_connections: "3" }, pool_config.configuration_hash) end diff --git a/activerecord/test/cases/pooled_connections_test.rb b/activerecord/test/cases/pooled_connections_test.rb index 6b93af89c3cf3..3412b64115a3c 100644 --- a/activerecord/test/cases/pooled_connections_test.rb +++ b/activerecord/test/cases/pooled_connections_test.rb @@ -32,7 +32,7 @@ def test_pooled_connection_checkin_two end def test_pooled_connection_remove - ActiveRecord::Base.establish_connection(@connection.merge(pool: 2, checkout_timeout: 0.5)) + ActiveRecord::Base.establish_connection(@connection.merge(max_connections: 2, checkout_timeout: 0.5)) old_connection = ActiveRecord::Base.lease_connection extra_connection = ActiveRecord::Base.connection_pool.checkout ActiveRecord::Base.connection_pool.remove(extra_connection) @@ -41,8 +41,8 @@ def test_pooled_connection_remove private # Will deadlock due to lack of Monitor timeouts in 1.9 - def checkout_checkin_connections(pool_size, threads) - ActiveRecord::Base.establish_connection(@connection.merge(pool: pool_size, checkout_timeout: 0.5)) + def checkout_checkin_connections(max_connections, threads) + ActiveRecord::Base.establish_connection(@connection.merge(max_connections: max_connections, checkout_timeout: 0.5)) @connection_count = 0 @timed_out = 0 threads.times do @@ -57,8 +57,8 @@ def checkout_checkin_connections(pool_size, threads) end end - def checkout_checkin_connections_loop(pool_size, loops) - ActiveRecord::Base.establish_connection(@connection.merge(pool: pool_size, checkout_timeout: 0.5)) + def checkout_checkin_connections_loop(max_connections, loops) + ActiveRecord::Base.establish_connection(@connection.merge(max_connections: max_connections, checkout_timeout: 0.5)) @connection_count = 0 @timed_out = 0 loops.times do diff --git a/railties/lib/rails/generators/rails/app/templates/config/databases/mysql.yml.tt b/railties/lib/rails/generators/rails/app/templates/config/databases/mysql.yml.tt index 4a9beaf7863af..b9ff1591cbaa2 100644 --- a/railties/lib/rails/generators/rails/app/templates/config/databases/mysql.yml.tt +++ b/railties/lib/rails/generators/rails/app/templates/config/databases/mysql.yml.tt @@ -12,7 +12,7 @@ default: &default adapter: mysql2 encoding: utf8mb4 - pool: <%%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> + max_connections: <%%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> username: root password: <% if database.socket -%> diff --git a/railties/lib/rails/generators/rails/app/templates/config/databases/postgresql.yml.tt b/railties/lib/rails/generators/rails/app/templates/config/databases/postgresql.yml.tt index fc7d4f6a95439..a199b8221d279 100644 --- a/railties/lib/rails/generators/rails/app/templates/config/databases/postgresql.yml.tt +++ b/railties/lib/rails/generators/rails/app/templates/config/databases/postgresql.yml.tt @@ -17,7 +17,7 @@ default: &default encoding: unicode # For details on connection pooling, see Rails configuration guide # https://guides.rubyonrails.org/configuring.html#database-pooling - pool: <%%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> + max_connections: <%%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> <% if devcontainer? -%> <%% if ENV["DB_HOST"] %> host: <%%= ENV["DB_HOST"] %> diff --git a/railties/lib/rails/generators/rails/app/templates/config/databases/sqlite3.yml.tt b/railties/lib/rails/generators/rails/app/templates/config/databases/sqlite3.yml.tt index f44d9f274ef51..1ec8cb5b81df2 100644 --- a/railties/lib/rails/generators/rails/app/templates/config/databases/sqlite3.yml.tt +++ b/railties/lib/rails/generators/rails/app/templates/config/databases/sqlite3.yml.tt @@ -6,7 +6,7 @@ # default: &default adapter: sqlite3 - pool: <%%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> + max_connections: <%%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> timeout: 5000 development: diff --git a/railties/lib/rails/generators/rails/app/templates/config/databases/trilogy.yml.tt b/railties/lib/rails/generators/rails/app/templates/config/databases/trilogy.yml.tt index e206129f23d57..7e1e08e15ebe7 100644 --- a/railties/lib/rails/generators/rails/app/templates/config/databases/trilogy.yml.tt +++ b/railties/lib/rails/generators/rails/app/templates/config/databases/trilogy.yml.tt @@ -12,7 +12,7 @@ default: &default adapter: trilogy encoding: utf8mb4 - pool: <%%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> + max_connections: <%%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> username: root password: host: <%%= ENV.fetch("DB_HOST") { "<%= database.host %>" } %> diff --git a/railties/test/application/dbconsole_test.rb b/railties/test/application/dbconsole_test.rb index 4ae077c9ec60e..3ee8a0fb9b826 100644 --- a/railties/test/application/dbconsole_test.rb +++ b/railties/test/application/dbconsole_test.rb @@ -23,7 +23,7 @@ def test_use_value_defined_in_environment_file_in_database_yml development: database: <%= Rails.application.config.database %> adapter: sqlite3 - pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> + max_connections: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> timeout: 5000 YAML @@ -44,7 +44,7 @@ def test_respect_environment_option app_file "config/database.yml", <<-YAML default: &default adapter: sqlite3 - pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> + max_connections: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> timeout: 5000 development: diff --git a/railties/test/application/multi_db_rake_test.rb b/railties/test/application/multi_db_rake_test.rb index 9f57c40d0d8fd..d9f492e084cce 100644 --- a/railties/test/application/multi_db_rake_test.rb +++ b/railties/test/application/multi_db_rake_test.rb @@ -36,7 +36,7 @@ def test_migrations_paths_takes_first app_file "config/database.yml", <<-YAML default: &default adapter: sqlite3 - pool: 5 + max_connections: 5 timeout: 5000 variables: statement_timeout: 1000 diff --git a/railties/test/application/rake/dbs_test.rb b/railties/test/application/rake/dbs_test.rb index 1bec731e4972e..3d5c9ce95caf7 100644 --- a/railties/test/application/rake/dbs_test.rb +++ b/railties/test/application/rake/dbs_test.rb @@ -486,7 +486,7 @@ def db_schema_cache_dump f.puts <<-YAML default: &default adapter: sqlite3 - pool: 5 + max_connections: 5 timeout: 5000 variables: statement_timeout: 1000 @@ -508,7 +508,7 @@ def db_schema_cache_dump f.puts <<-YAML default: &default adapter: sqlite3 - pool: 5 + max_connections: 5 timeout: 5000 variables: statement_timeout: 1000 diff --git a/railties/test/commands/dbconsole_test.rb b/railties/test/commands/dbconsole_test.rb index bc0f77d845756..168a2328f9643 100644 --- a/railties/test/commands/dbconsole_test.rb +++ b/railties/test/commands/dbconsole_test.rb @@ -29,7 +29,7 @@ def test_config_with_db_config_only "database" => "foo_test", "user" => "foo", "password" => "bar", - "pool" => "5", + "max_connections" => "5", "timeout" => "3000" } } @@ -47,16 +47,16 @@ def test_config_with_no_db_config end def test_config_with_database_url_only - ENV["DATABASE_URL"] = "postgresql://foo:bar@localhost:9000/foo_test?pool=5&timeout=3000" + ENV["DATABASE_URL"] = "postgresql://foo:bar@localhost:9000/foo_test?max_connections=5&timeout=3000" expected = { - adapter: "postgresql", - host: "localhost", - port: 9000, - database: "foo_test", - username: "foo", - password: "bar", - pool: "5", - timeout: "3000" + adapter: "postgresql", + host: "localhost", + port: 9000, + database: "foo_test", + username: "foo", + password: "bar", + max_connections: "5", + timeout: "3000" }.sort app_db_config(nil) do @@ -66,17 +66,17 @@ def test_config_with_database_url_only def test_config_choose_database_url_if_exists host = "database-url-host.com" - ENV["DATABASE_URL"] = "postgresql://foo:bar@#{host}:9000/foo_test?pool=5&timeout=3000" + ENV["DATABASE_URL"] = "postgresql://foo:bar@#{host}:9000/foo_test?max_connections=5&timeout=3000" sample_config = { "test" => { - "adapter" => "postgresql", - "host" => "not-the-#{host}", - "port" => 9000, - "database" => "foo_test", - "username" => "foo", - "password" => "bar", - "pool" => "5", - "timeout" => "3000" + "adapter" => "postgresql", + "host" => "not-the-#{host}", + "port" => 9000, + "database" => "foo_test", + "username" => "foo", + "password" => "bar", + "max_connections" => "5", + "timeout" => "3000" } } app_db_config(sample_config) do diff --git a/railties/test/isolation/abstract_unit.rb b/railties/test/isolation/abstract_unit.rb index 29f9f16dc45a1..d7208a0ca9a66 100644 --- a/railties/test/isolation/abstract_unit.rb +++ b/railties/test/isolation/abstract_unit.rb @@ -155,7 +155,7 @@ def default_database_configs <<-YAML default: &default adapter: sqlite3 - pool: 5 + max_connections: 5 timeout: 5000 development: <<: *default @@ -173,7 +173,7 @@ def multi_db_database_configs <<-YAML default: &default adapter: sqlite3 - pool: 5 + max_connections: 5 timeout: 5000 variables: statement_timeout: 1000 @@ -501,7 +501,7 @@ def use_postgresql(multi_db: false) f.puts <<-YAML default: &default adapter: postgresql - pool: 5 + max_connections: 5 development: primary: <<: *default @@ -517,7 +517,7 @@ def use_postgresql(multi_db: false) f.puts <<-YAML default: &default adapter: postgresql - pool: 5 + max_connections: 5 development: <<: *default database: #{database_name}_development @@ -537,7 +537,7 @@ def use_mysql2(multi_db: false) f.puts <<-YAML default: &default adapter: mysql2 - pool: 5 + max_connections: 5 username: root <% if ENV['MYSQL_CODESPACES'] %> password: 'root' @@ -563,7 +563,7 @@ def use_mysql2(multi_db: false) f.puts <<-YAML default: &default adapter: mysql2 - pool: 5 + max_connections: 5 username: root <% if ENV['MYSQL_CODESPACES'] %> password: 'root' From 79285ed010cefe46b53fa1feeb5adfecf75d6245 Mon Sep 17 00:00:00 2001 From: ChaelCodes Date: Tue, 14 Jan 2025 16:52:24 +0000 Subject: [PATCH 0543/1075] Add changelog entry for new pool configs Co-authored-by: Matthew Draper --- activerecord/CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/activerecord/CHANGELOG.md b/activerecord/CHANGELOG.md index c31dd2d0fcca1..1c8e84cb030c9 100644 --- a/activerecord/CHANGELOG.md +++ b/activerecord/CHANGELOG.md @@ -1,3 +1,11 @@ +* Introduce new database configuration options `keepalive`, `max_age`, and + `min_connections` -- and rename `pool` to `max_connections` to match. + + There are no changes to default behavior, but these allow for more specific + control over pool behavior. + + *Matthew Draper*, *Chris AtLee*, *Rachael Wright-Munn* + * Move `LIMIT` validation from query generation to when `limit()` is called. *Hartley McGuire*, *Shuyang* From b08ae16bb6641755531dfe9ae6bb2bd34351e09a Mon Sep 17 00:00:00 2001 From: Matthew Draper Date: Thu, 20 Feb 2025 01:31:15 +1030 Subject: [PATCH 0544/1075] Apply a pool_jitter adjustment to keepalive and max_age For each connection, we'll reduce both values by up to 20% (by default), preventing repeated thundering herds. Co-authored-by: ChaelCodes --- .../abstract/connection_pool.rb | 6 +- .../connection_adapters/abstract_adapter.rb | 11 +++ .../test/cases/connection_pool_test.rb | 83 +++++++++++++++++-- 3 files changed, 93 insertions(+), 7 deletions(-) diff --git a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb index ebdfacf3d2d19..884b9487c7a0f 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb @@ -117,6 +117,8 @@ def pool_transaction_isolation_level=(isolation_level) # exist before retiring it at next checkin. (default Float::INFINITY). # * +max_connections+: maximum number of connections the pool may manage (default 5). # * +min_connections+: minimum number of connections the pool will open and maintain (default 0). + # * +pool_jitter+: maximum reduction factor to apply to +max_age+ and + # +keepalive+ intervals (default 0.2; range 0.0-1.0). # #-- # Synchronization policy: @@ -761,7 +763,7 @@ def prepopulate def retire_old_connections(max_age = @max_age) max_age ||= Float::INFINITY - sequential_maintenance -> c { c.connection_age&.>= max_age } do |conn| + sequential_maintenance -> c { c.connection_age&.>= c.pool_jitter(max_age) } do |conn| # Disconnect, then return the adapter to the pool. Preconnect will # handle the rest. conn.disconnect! @@ -789,7 +791,7 @@ def preconnect def keep_alive(threshold = @keepalive) return if threshold.nil? - sequential_maintenance -> c { (c.seconds_since_last_activity || 0) > threshold } do |conn| + sequential_maintenance -> c { (c.seconds_since_last_activity || 0) > c.pool_jitter(threshold) } do |conn| # conn.active? will cause some amount of network activity, which is all # we need to provide a keepalive signal. # diff --git a/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb b/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb index 7686575b21cf8..e835a3e804b53 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb @@ -173,6 +173,8 @@ def initialize(config_or_deprecated_connection, deprecated_logger = nil, depreca @raw_connection_dirty = false @last_activity = nil @verified = false + + @pool_jitter = rand * max_jitter end def inspect # :nodoc: @@ -200,6 +202,11 @@ def check_if_write_query(sql) # :nodoc: end end + MAX_JITTER = 0.0..1.0 # :nodoc: + def max_jitter + (@config[:pool_jitter] || 0.2).to_f.clamp(MAX_JITTER) + end + def replica? @config[:replica] || false end @@ -307,6 +314,10 @@ def schema_cache @pool.schema_cache || (@schema_cache ||= BoundSchemaReflection.for_lone_connection(@pool.schema_reflection, self)) end + def pool_jitter(duration) + duration * (1.0 - @pool_jitter) + end + # this method must only be called while holding connection pool's mutex def expire(update_idle = true) # :nodoc: if in_use? diff --git a/activerecord/test/cases/connection_pool_test.rb b/activerecord/test/cases/connection_pool_test.rb index 5409544cd46d2..5b894de7812cd 100644 --- a/activerecord/test/cases/connection_pool_test.rb +++ b/activerecord/test/cases/connection_pool_test.rb @@ -455,7 +455,7 @@ def test_preconnect end def test_max_age - pool = new_pool_with_options(max_age: 10, async: false) + pool = new_pool_with_options(max_age: 10, pool_jitter: 0, async: false) conn = pool.checkout @@ -478,6 +478,52 @@ def test_max_age assert_not_predicate conn, :connected? end + def test_max_age_with_jitter + pool = new_pool_with_options(max_age: 20, pool_jitter: 0, async: false) + + conn = pool.checkout + conn.instance_variable_set(:@pool_jitter, 0.5) + + assert_not_predicate conn, :connected? + assert_nil conn.connection_age + + conn.connect! + + assert_predicate conn, :connected? + assert_operator conn.connection_age, :>=, 0 + assert_operator conn.connection_age, :<, 1 + + conn.instance_variable_set(:@connected_since, Process.clock_gettime(Process::CLOCK_MONOTONIC) - 11) + + assert_operator conn.connection_age, :>, 10 + + pool.checkin conn + pool.retire_old_connections + + assert_not_predicate conn, :connected? + end + + def test_jitter_calculated_on_new_connections + pool = new_pool_with_options(max_age: 10, pool_jitter: 0.5, async: false) + + conns = 4.times.map { pool.checkout } + + observed_jitters = conns.map { |conn| conn.instance_variable_get(:@pool_jitter) } + + assert_operator observed_jitters.min, :>=, 0.0 + assert_operator observed_jitters.max, :>, 0.0 # statistically impossible to get all zeros + assert_operator observed_jitters.max, :<=, 0.5 + end + + def test_jitter_evaluation + pool = new_pool_with_options(max_age: 10, pool_jitter: 0.5, async: false) + conn = pool.checkout + + conn.instance_variable_set(:@pool_jitter, 0.25) + + assert_equal 75, conn.pool_jitter(100) + end + def test_explicit_retirement pool = new_pool_with_options(max_age: nil, async: false) @@ -503,7 +549,7 @@ def test_explicit_retirement end def test_keepalive - pool = new_pool_with_options(keepalive: 100, async: false) + pool = new_pool_with_options(keepalive: 100, pool_jitter: 0, async: false) conn = pool.checkout conn.connect! pool.checkin conn @@ -520,7 +566,34 @@ def test_keepalive # still about the same age assert_in_epsilon 50, conn.seconds_since_last_activity, 10 - conn.instance_variable_set(:@last_activity, Process.clock_gettime(Process::CLOCK_MONOTONIC) - 200) + conn.instance_variable_set(:@last_activity, Process.clock_gettime(Process::CLOCK_MONOTONIC) - 150) + + pool.keep_alive + + # keep-alive query occurred, so our activity time has reset + assert_operator conn.seconds_since_last_activity, :<, 5 + end + + def test_keepalive_with_jitter + pool = new_pool_with_options(keepalive: 200, pool_jitter: 0, async: false) + conn = pool.checkout + conn.instance_variable_set(:@pool_jitter, 0.5) + conn.connect! + pool.checkin conn + + assert_operator conn.seconds_since_last_activity, :<, 5 + + conn.instance_variable_set(:@last_activity, Process.clock_gettime(Process::CLOCK_MONOTONIC) - 50) + + assert_in_epsilon 50, conn.seconds_since_last_activity, 10 + + # we're currently below the threshold, so this is a no-op + pool.keep_alive + + # still about the same age + assert_in_epsilon 50, conn.seconds_since_last_activity, 10 + + conn.instance_variable_set(:@last_activity, Process.clock_gettime(Process::CLOCK_MONOTONIC) - 150) pool.keep_alive @@ -529,7 +602,7 @@ def test_keepalive end def test_keepalive_notices_problems - pool = new_pool_with_options(keepalive: 100, async: false) + pool = new_pool_with_options(keepalive: 100, pool_jitter: 0, async: false) conn = pool.checkout conn.connect! pool.checkin conn @@ -571,7 +644,7 @@ def test_keepalive_notices_problems end def test_idle_through_keepalive - pool = new_pool_with_options(keepalive: 0.1, idle_timeout: 0.5, async: false) + pool = new_pool_with_options(keepalive: 0.1, pool_jitter: 0, idle_timeout: 0.5, async: false) conn = pool.checkout conn.connect! pool.checkin conn From 3e3f97e6c4be1ab5f28c73a380bb6caf0125d4ee Mon Sep 17 00:00:00 2001 From: Matthew Draper Date: Thu, 20 Feb 2025 02:28:57 +1030 Subject: [PATCH 0545/1075] Reduce default reaping frequency now that it has more jobs Lower the baseline to 20s, but also reduce it further if that's needed in order to usefully achieve one of our configured timeouts. Co-authored-by: ChaelCodes --- .../database_configurations/hash_config.rb | 12 ++++++++---- activerecord/test/cases/connection_pool_test.rb | 2 +- .../database_configurations/hash_config_test.rb | 7 ++++++- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/activerecord/lib/active_record/database_configurations/hash_config.rb b/activerecord/lib/active_record/database_configurations/hash_config.rb index 46cba9f746420..e2f6f9ba3c98b 100644 --- a/activerecord/lib/active_record/database_configurations/hash_config.rb +++ b/activerecord/lib/active_record/database_configurations/hash_config.rb @@ -112,10 +112,8 @@ def checkout_timeout (configuration_hash[:checkout_timeout] || 5).to_f end - # `reaping_frequency` is configurable mostly for historical reasons, but it - # could also be useful if someone wants a very low `idle_timeout`. - def reaping_frequency - configuration_hash.fetch(:reaping_frequency, 60)&.to_f + def reaping_frequency # :nodoc: + configuration_hash.fetch(:reaping_frequency, default_reaping_frequency)&.to_f end def idle_timeout @@ -205,6 +203,12 @@ def schema_file_type(format) "structure.sql" end end + + def default_reaping_frequency + # Reap every 20 seconds by default, but run more often as necessary to + # meet other configured timeouts. + [20, idle_timeout, max_age, keepalive].compact.min + end end end end diff --git a/activerecord/test/cases/connection_pool_test.rb b/activerecord/test/cases/connection_pool_test.rb index 5b894de7812cd..d59a0e3a040e8 100644 --- a/activerecord/test/cases/connection_pool_test.rb +++ b/activerecord/test/cases/connection_pool_test.rb @@ -644,7 +644,7 @@ def test_keepalive_notices_problems end def test_idle_through_keepalive - pool = new_pool_with_options(keepalive: 0.1, pool_jitter: 0, idle_timeout: 0.5, async: false) + pool = new_pool_with_options(keepalive: 0.1, pool_jitter: 0, idle_timeout: 0.5, async: false, reaping_frequency: 30) conn = pool.checkout conn.connect! pool.checkin conn diff --git a/activerecord/test/cases/database_configurations/hash_config_test.rb b/activerecord/test/cases/database_configurations/hash_config_test.rb index b9cb8fd88a147..50cc545cbf06e 100644 --- a/activerecord/test/cases/database_configurations/hash_config_test.rb +++ b/activerecord/test/cases/database_configurations/hash_config_test.rb @@ -140,7 +140,12 @@ def test_reaping_frequency_overrides_with_value def test_when_no_reaping_frequency_uses_default config = HashConfig.new("default_env", "primary", adapter: "abstract") - assert_equal 60.0, config.reaping_frequency + assert_equal 20.0, config.reaping_frequency + end + + def test_reaping_frequency_is_reduced_for_low_keepalive + config = HashConfig.new("default_env", "primary", keepalive: "15", adapter: "abstract") + assert_equal 15.0, config.reaping_frequency end def test_idle_timeout_default_when_nil From d812a15802f828797735a098cdc198113239754d Mon Sep 17 00:00:00 2001 From: ChaelCodes Date: Wed, 19 Feb 2025 21:24:52 +0000 Subject: [PATCH 0546/1075] Test with larger idle timeouts when we're not sleeping --- activerecord/test/cases/connection_pool_test.rb | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/activerecord/test/cases/connection_pool_test.rb b/activerecord/test/cases/connection_pool_test.rb index d59a0e3a040e8..8af8989be121e 100644 --- a/activerecord/test/cases/connection_pool_test.rb +++ b/activerecord/test/cases/connection_pool_test.rb @@ -261,13 +261,13 @@ def test_inactive_are_returned_from_dead_thread def test_idle_timeout_configuration @pool.disconnect! - @pool = new_pool_with_options(idle_timeout: "0.02") + @pool = new_pool_with_options(idle_timeout: "200") idle_conn = @pool.checkout @pool.checkin(idle_conn) idle_conn.instance_variable_set( :@idle_since, - Process.clock_gettime(Process::CLOCK_MONOTONIC) - 0.01 + Process.clock_gettime(Process::CLOCK_MONOTONIC) - 199 ) @pool.flush @@ -275,7 +275,7 @@ def test_idle_timeout_configuration idle_conn.instance_variable_set( :@idle_since, - Process.clock_gettime(Process::CLOCK_MONOTONIC) - 0.03 + Process.clock_gettime(Process::CLOCK_MONOTONIC) - 201 ) @pool.flush @@ -295,14 +295,14 @@ def test_min_connections_configuration def test_idle_timeout_configuration_with_min_connections @pool.disconnect! - @pool = new_pool_with_options(idle_timeout: "0.02", min_connections: 1) + @pool = new_pool_with_options(idle_timeout: "200", min_connections: 1) connections = 2.times.map { @pool.checkout } connections.each { |conn| @pool.checkin(conn) } connections.each do |conn| conn.instance_variable_set( :@idle_since, - Process.clock_gettime(Process::CLOCK_MONOTONIC) - 0.01 + Process.clock_gettime(Process::CLOCK_MONOTONIC) - 199 ) end @@ -312,7 +312,7 @@ def test_idle_timeout_configuration_with_min_connections connections.each do |conn| conn.instance_variable_set( :@idle_since, - Process.clock_gettime(Process::CLOCK_MONOTONIC) - 0.03 + Process.clock_gettime(Process::CLOCK_MONOTONIC) - 201 ) end From f591b43fc0d6550ced6dbdac576379ec1e225f44 Mon Sep 17 00:00:00 2001 From: Matthew Draper Date: Wed, 5 Mar 2025 16:22:34 +1030 Subject: [PATCH 0547/1075] Remove a false claim from the documentation The reaper thread has long been responsible for collecting dead threads' connections; that doesn't happen in `clear_active_connections!`. --- .../connection_adapters/abstract/connection_handler.rb | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/activerecord/lib/active_record/connection_adapters/abstract/connection_handler.rb b/activerecord/lib/active_record/connection_adapters/abstract/connection_handler.rb index 8cad102bdae04..7f58292c8be49 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/connection_handler.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/connection_handler.rb @@ -158,9 +158,7 @@ def active_connections?(role = nil) each_connection_pool(role).any?(&:active_connection?) end - # Returns any connections in use by the current thread back to the pool, - # and also returns connections to the pool cached by threads that are no - # longer alive. + # Returns any connections in use by the current thread back to the pool. def clear_active_connections!(role = nil) each_connection_pool(role).each do |pool| pool.release_connection From 235b687bed30081baf52d05f91f882cb45b5a9b6 Mon Sep 17 00:00:00 2001 From: Matthew Draper Date: Wed, 5 Mar 2025 16:25:43 +1030 Subject: [PATCH 0548/1075] Unify definitions of with_temporary_connection_pool Also, add a couple of disconnect! calls for other locally-constructed temporary pools. --- activerecord/test/cases/attribute_methods_test.rb | 9 --------- .../cases/connection_adapters/adapter_leasing_test.rb | 2 ++ activerecord/test/cases/connection_pool_test.rb | 4 ++++ activerecord/test/cases/query_cache_test.rb | 9 --------- activerecord/test/cases/schema_loading_test.rb | 9 --------- activerecord/test/cases/test_case.rb | 9 +++++++++ 6 files changed, 15 insertions(+), 27 deletions(-) diff --git a/activerecord/test/cases/attribute_methods_test.rb b/activerecord/test/cases/attribute_methods_test.rb index 6aa284fc18934..605c334e36837 100644 --- a/activerecord/test/cases/attribute_methods_test.rb +++ b/activerecord/test/cases/attribute_methods_test.rb @@ -1157,8 +1157,6 @@ def name assert_no_queries(include_schema: true) do @target.define_attribute_methods end - ensure - ActiveRecord::Base.connection_pool.disconnect! end end @@ -1631,11 +1629,4 @@ def #{method_signature} end private_method end - - def with_temporary_connection_pool(&block) - pool_config = ActiveRecord::Base.lease_connection.pool.pool_config - new_pool = ActiveRecord::ConnectionAdapters::ConnectionPool.new(pool_config) - - pool_config.stub(:pool, new_pool, &block) - end end diff --git a/activerecord/test/cases/connection_adapters/adapter_leasing_test.rb b/activerecord/test/cases/connection_adapters/adapter_leasing_test.rb index 8e0261b473975..bb5e306762c7f 100644 --- a/activerecord/test/cases/connection_adapters/adapter_leasing_test.rb +++ b/activerecord/test/cases/connection_adapters/adapter_leasing_test.rb @@ -54,6 +54,8 @@ def test_close assert_not_predicate @adapter, :in_use? assert_equal @adapter, pool.lease_connection + ensure + pool&.disconnect! end end end diff --git a/activerecord/test/cases/connection_pool_test.rb b/activerecord/test/cases/connection_pool_test.rb index 8af8989be121e..e31e2a46424f9 100644 --- a/activerecord/test/cases/connection_pool_test.rb +++ b/activerecord/test/cases/connection_pool_test.rb @@ -1348,6 +1348,8 @@ def test_role_and_shard_is_returned assert_equal :shard_one, pool_config.shard assert_equal :shard_one, pool.shard assert_equal :shard_one, pool.lease_connection.shard + ensure + pool&.disconnect! end def test_pin_connection_always_returns_the_same_connection @@ -1471,6 +1473,8 @@ def test_inspect_does_not_show_secrets pool = ConnectionPool.new(pool_config) assert_match(/#/, pool.inspect) + ensure + pool&.disconnect! end private diff --git a/activerecord/test/cases/query_cache_test.rb b/activerecord/test/cases/query_cache_test.rb index 25975f5db2a02..448133f7632a9 100644 --- a/activerecord/test/cases/query_cache_test.rb +++ b/activerecord/test/cases/query_cache_test.rb @@ -339,8 +339,6 @@ def test_query_cache_across_threads ActiveRecord::Base.connection_pool.connections.each do |conn| assert_cache :off, conn end - ensure - ActiveRecord::Base.connection_pool.disconnect! end end @@ -830,13 +828,6 @@ def test_query_cache_uncached_dirties_disabled_with_nested_cache end private - def with_temporary_connection_pool(&block) - pool_config = ActiveRecord::Base.lease_connection.pool.pool_config - new_pool = ActiveRecord::ConnectionAdapters::ConnectionPool.new(pool_config) - - pool_config.stub(:pool, new_pool, &block) - end - def middleware(&app) executor = Class.new(ActiveSupport::Executor) ActiveRecord::QueryCache.install_executor_hooks executor diff --git a/activerecord/test/cases/schema_loading_test.rb b/activerecord/test/cases/schema_loading_test.rb index 2e3497cd76f93..534304fe09a3c 100644 --- a/activerecord/test/cases/schema_loading_test.rb +++ b/activerecord/test/cases/schema_loading_test.rb @@ -69,8 +69,6 @@ def test_schema_loading_doesnt_query_when_schema_cache_is_loaded klass.load_schema end assert_equal 1, klass.load_schema_calls - ensure - ActiveRecord::Base.connection_pool.disconnect! end end @@ -82,11 +80,4 @@ def define_model yield self if block_given? end end - - def with_temporary_connection_pool(&block) - pool_config = ActiveRecord::Base.lease_connection.pool.pool_config - new_pool = ActiveRecord::ConnectionAdapters::ConnectionPool.new(pool_config) - - pool_config.stub(:pool, new_pool, &block) - end end diff --git a/activerecord/test/cases/test_case.rb b/activerecord/test/cases/test_case.rb index bf507a0d9cddc..605e9fd8244aa 100644 --- a/activerecord/test/cases/test_case.rb +++ b/activerecord/test/cases/test_case.rb @@ -190,6 +190,15 @@ def reset_callbacks(klass, kind) end end + def with_temporary_connection_pool(&block) + pool_config = ActiveRecord::Base.connection_pool.pool_config + new_pool = ActiveRecord::ConnectionAdapters::ConnectionPool.new(pool_config) + + pool_config.stub(:pool, new_pool, &block) + ensure + new_pool&.disconnect! + end + def with_postgresql_datetime_type(type) adapter = ActiveRecord::ConnectionAdapters::PostgreSQLAdapter adapter.remove_instance_variable(:@native_database_types) if adapter.instance_variable_defined?(:@native_database_types) From 727ad99ed8ffa860b1abce289d5f0fcc01c657ca Mon Sep 17 00:00:00 2001 From: Matthew Draper Date: Wed, 5 Mar 2025 16:27:44 +1030 Subject: [PATCH 0549/1075] No need to lease a connection just to check adapter type --- activerecord/test/support/adapter_helper.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/activerecord/test/support/adapter_helper.rb b/activerecord/test/support/adapter_helper.rb index edf522fc74926..7a5b1a80a36c4 100644 --- a/activerecord/test/support/adapter_helper.rb +++ b/activerecord/test/support/adapter_helper.rb @@ -4,7 +4,7 @@ module AdapterHelper def current_adapter?(*types) types.any? do |type| ActiveRecord::ConnectionAdapters.const_defined?(type) && - ActiveRecord::Base.lease_connection.is_a?(ActiveRecord::ConnectionAdapters.const_get(type)) + ActiveRecord::Base.connection_pool.db_config.adapter_class <= ActiveRecord::ConnectionAdapters.const_get(type) end end From 4a8bece0519f48b89887a99d62fdf15d9146ac0a Mon Sep 17 00:00:00 2001 From: Matthew Draper Date: Wed, 12 Mar 2025 01:56:04 +1030 Subject: [PATCH 0550/1075] Keep a central definition of whether there's any reaping work to do --- .../connection_adapters/abstract/connection_pool.rb | 6 ++++++ .../connection_adapters/abstract/connection_pool/reaper.rb | 1 + activerecord/test/cases/reaper_test.rb | 4 ++++ 3 files changed, 11 insertions(+) diff --git a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb index 884b9487c7a0f..58df5104ae53f 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb @@ -547,6 +547,12 @@ def discarded? # :nodoc: @connections.nil? end + def maintainable? # :nodoc: + synchronize do + @connections&.size&.> 0 || (activated? && @min_connections > 0) + end + end + # Clears reloadable connections from the pool and re-connects connections that # require reloading. # diff --git a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool/reaper.rb b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool/reaper.rb index 0a72129833ef7..46c66a2ef4097 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool/reaper.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool/reaper.rb @@ -70,6 +70,7 @@ def spawn_thread(frequency) @pools[frequency].each do |ref| p = ref.__getobj__ + next unless p.maintainable? p.reap p.flush diff --git a/activerecord/test/cases/reaper_test.rb b/activerecord/test/cases/reaper_test.rb index 8ae3dd2c28776..706a25b9dbe7e 100644 --- a/activerecord/test/cases/reaper_test.rb +++ b/activerecord/test/cases/reaper_test.rb @@ -41,6 +41,10 @@ def keep_alive def retire_old_connections end + + def maintainable? + !discarded? && !flushed && !reaped + end end # A reaper with nil time should never reap connections From e45d8acc746e32992ab7070864519d2f50add5ac Mon Sep 17 00:00:00 2001 From: Matthew Draper Date: Wed, 12 Mar 2025 02:01:57 +1030 Subject: [PATCH 0551/1075] Lock the pool while performing global operations If a caller wants to do something that affects the entire pool contents (like full #disconnect), we need to ensure we're not fighting with the reaper. --- .../abstract/connection_pool.rb | 63 ++++++++++++------- .../abstract/connection_pool/reaper.rb | 14 +++-- activerecord/test/cases/reaper_test.rb | 4 ++ 3 files changed, 52 insertions(+), 29 deletions(-) diff --git a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb index 58df5104ae53f..2bf40438e8906 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb @@ -297,6 +297,7 @@ def initialize(pool_config) @activated = false @original_context = ActiveSupport::IsolatedExecutionState.context + @reaper_lock = Monitor.new @reaper = Reaper.new(self, db_config.reaping_frequency) @reaper.run end @@ -502,18 +503,20 @@ def connections # connections in the pool within a timeout interval (default duration is # spec.db_config.checkout_timeout * 2 seconds). def disconnect(raise_on_acquisition_timeout = true) - with_exclusively_acquired_all_connections(raise_on_acquisition_timeout) do - synchronize do - @connections.each do |conn| - if conn.in_use? - conn.steal! - checkin conn + @reaper_lock.synchronize do + with_exclusively_acquired_all_connections(raise_on_acquisition_timeout) do + synchronize do + @connections.each do |conn| + if conn.in_use? + conn.steal! + checkin conn + end + conn.disconnect! end - conn.disconnect! + @connections = [] + @leases.clear + @available.clear end - @connections = [] - @leases.clear - @available.clear end end end @@ -534,12 +537,14 @@ def disconnect! # # See AbstractAdapter#discard! def discard! # :nodoc: - synchronize do - return if self.discarded? - @connections.each do |conn| - conn.discard! + @reaper_lock.synchronize do + synchronize do + return if self.discarded? + @connections.each do |conn| + conn.discard! + end + @connections = @available = @leases = nil end - @connections = @available = @leases = nil end end @@ -553,6 +558,10 @@ def maintainable? # :nodoc: end end + def reaper_lock(&block) # :nodoc: + @reaper_lock.synchronize(&block) + end + # Clears reloadable connections from the pool and re-connects connections that # require reloading. # @@ -753,13 +762,19 @@ def flush! # Ensure that the pool contains at least the configured minimum number of # connections. def prepopulate - return if self.discarded? + need_new_connections = nil - # We don't want to start prepopulating until we know the pool is wanted, - # so we can avoid maintaining full pools in one-off scripts etc. - return unless @activated + synchronize do + return if self.discarded? + + # We don't want to start prepopulating until we know the pool is wanted, + # so we can avoid maintaining full pools in one-off scripts etc. + return unless @activated + + need_new_connections = @connections.size < @min_connections + end - if @connections.size < @min_connections + if need_new_connections while new_conn = try_to_checkout_new_connection { @connections.size < @min_connections } checkin(new_conn) end @@ -1008,9 +1023,11 @@ def bulk_make_new_connections(num_new_conns_needed) # wrap it in +synchronize+ because some pool's actions are allowed # to be performed outside of the main +synchronize+ block. def with_exclusively_acquired_all_connections(raise_on_acquisition_timeout = true) - with_new_connections_blocked do - attempt_to_checkout_all_existing_connections(raise_on_acquisition_timeout) - yield + @reaper_lock.synchronize do + with_new_connections_blocked do + attempt_to_checkout_all_existing_connections(raise_on_acquisition_timeout) + yield + end end end diff --git a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool/reaper.rb b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool/reaper.rb index 46c66a2ef4097..fbdeffe1317b4 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool/reaper.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool/reaper.rb @@ -72,12 +72,14 @@ def spawn_thread(frequency) p = ref.__getobj__ next unless p.maintainable? - p.reap - p.flush - p.prepopulate - p.retire_old_connections - p.keep_alive - p.preconnect + pool.reaper_lock do + p.reap + p.flush + p.prepopulate + p.retire_old_connections + p.keep_alive + p.preconnect + end rescue WeakRef::RefError end diff --git a/activerecord/test/cases/reaper_test.rb b/activerecord/test/cases/reaper_test.rb index 706a25b9dbe7e..358527120f95f 100644 --- a/activerecord/test/cases/reaper_test.rb +++ b/activerecord/test/cases/reaper_test.rb @@ -45,6 +45,10 @@ def retire_old_connections def maintainable? !discarded? && !flushed && !reaped end + + def reaper_lock + yield + end end # A reaper with nil time should never reap connections From d4ec909c8161a47768eeaf4b38fef539bc48cbb4 Mon Sep 17 00:00:00 2001 From: Matthew Draper Date: Wed, 12 Mar 2025 02:04:07 +1030 Subject: [PATCH 0552/1075] More checks for discarded pool --- .../abstract/connection_pool.rb | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb index 2bf40438e8906..d481e2442c5a7 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb @@ -421,6 +421,8 @@ def active_connection? # #lease_connection or #with_connection methods, connections obtained through # #checkout will not be automatically released. def release_connection(existing_lease = nil) + return if self.discarded? + if conn = connection_lease.release checkin conn return true @@ -504,8 +506,11 @@ def connections # spec.db_config.checkout_timeout * 2 seconds). def disconnect(raise_on_acquisition_timeout = true) @reaper_lock.synchronize do + return if self.discarded? + with_exclusively_acquired_all_connections(raise_on_acquisition_timeout) do synchronize do + return if self.discarded? @connections.each do |conn| if conn.in_use? conn.steal! @@ -1210,6 +1215,8 @@ def try_to_checkout_new_connection # and increment @now_connecting, to prevent overstepping this pool's @max_connections # constraint do_checkout = synchronize do + return if self.discarded? + if @threads_blocking_new_connections.zero? && (@connections.size + @now_connecting) < @max_connections && (!block_given? || yield) if @connections.size > 0 || @original_context != ActiveSupport::IsolatedExecutionState.context @activated = true @@ -1225,12 +1232,16 @@ def try_to_checkout_new_connection conn = checkout_new_connection ensure synchronize do + @now_connecting -= 1 if conn - adopt_connection(conn) - # returned conn needs to be already leased - conn.lease + if self.discarded? + conn.discard! + else + adopt_connection(conn) + # returned conn needs to be already leased + conn.lease + end end - @now_connecting -= 1 end end end From 69ea1915bbe39e1e1780ccaa84e191265f161425 Mon Sep 17 00:00:00 2001 From: Matthew Draper Date: Wed, 12 Mar 2025 02:04:42 +1030 Subject: [PATCH 0553/1075] After full disconnect, stop maintaining minimum size It's unlikely the caller expects the pool to immediately repopulate. --- .../connection_adapters/abstract/connection_pool.rb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb index d481e2442c5a7..5c4e0b012c00a 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb @@ -521,6 +521,9 @@ def disconnect(raise_on_acquisition_timeout = true) @connections = [] @leases.clear @available.clear + + # Stop maintaining the minimum size until reactivated + @activated = false end end end From 5214b1cd1c63b6cf90cef335e3e3a4138cd3f709 Mon Sep 17 00:00:00 2001 From: Matthew Draper Date: Wed, 12 Mar 2025 02:06:52 +1030 Subject: [PATCH 0554/1075] Be more structured in selecting pools for reaping --- .../abstract/connection_pool/reaper.rb | 45 ++++++++++++------- 1 file changed, 29 insertions(+), 16 deletions(-) diff --git a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool/reaper.rb b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool/reaper.rb index fbdeffe1317b4..26ae3342caf7d 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool/reaper.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool/reaper.rb @@ -53,6 +53,15 @@ def register_pool(pool, frequency) # :nodoc: end end + def pools(refs = nil) # :nodoc: + refs ||= @mutex.synchronize { @pools.values.flatten(1) } + + refs.filter_map do |ref| + ref.__getobj__ if ref.weakref_alive? + rescue WeakRef::RefError + end.select(&:maintainable?) + end + private def spawn_thread(frequency) Thread.new(frequency) do |t| @@ -63,32 +72,36 @@ def spawn_thread(frequency) running = true while running sleep t - @mutex.synchronize do - @pools[frequency].select! do |pool| - pool.weakref_alive? && !pool.discarded? - end - @pools[frequency].each do |ref| - p = ref.__getobj__ - next unless p.maintainable? + refs = nil - pool.reaper_lock do - p.reap - p.flush - p.prepopulate - p.retire_old_connections - p.keep_alive - p.preconnect - end + @mutex.synchronize do + refs = @pools[frequency] + + refs.select! do |pool| + pool.weakref_alive? && !pool.discarded? rescue WeakRef::RefError end - if @pools[frequency].empty? + if refs.empty? @pools.delete(frequency) @threads.delete(frequency) running = false end end + + if running + pools(refs).each do |pool| + pool.reaper_lock do + pool.reap + pool.flush + pool.prepopulate + pool.retire_old_connections + pool.keep_alive + pool.preconnect + end + end + end end end end From e6cac889400b371a4c4bcd4a26e0cdb18b1296b9 Mon Sep 17 00:00:00 2001 From: John Hawthorn Date: Wed, 3 Sep 2025 06:02:19 -0700 Subject: [PATCH 0555/1075] Use self-closing tag in epub/layout.html.erb In HTML rather than xhtml (which this file is declared as) meta and link are void elements, so they can't be closed. We can use a self-closing tag here so that it is compatible with both xhtml and HTML. --- guides/source/epub/layout.html.erb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/guides/source/epub/layout.html.erb b/guides/source/epub/layout.html.erb index f62ac9151666d..202850fda2539 100644 --- a/guides/source/epub/layout.html.erb +++ b/guides/source/epub/layout.html.erb @@ -1,8 +1,8 @@ - - + + <%= yield(:page_title) || 'Ruby on Rails Guides' %> From 4548704970b376ea2aaf8512a1e1bc0ab1b2be2c Mon Sep 17 00:00:00 2001 From: John Hawthorn Date: Wed, 3 Sep 2025 06:07:17 -0700 Subject: [PATCH 0556/1075] Fix missing tr in preview_docs --- tools/preview_docs/index.html.erb | 1 + 1 file changed, 1 insertion(+) diff --git a/tools/preview_docs/index.html.erb b/tools/preview_docs/index.html.erb index 21abb4a532511..6295749b5b869 100644 --- a/tools/preview_docs/index.html.erb +++ b/tools/preview_docs/index.html.erb @@ -67,6 +67,7 @@ <%= @build %> <%= @commit %> <%= @pull_request %> + Repo Branch From 1ca03c1cf3d0deb3e62ff49a46b3f0e8caeeeb6c Mon Sep 17 00:00:00 2001 From: Matthew Draper Date: Wed, 3 Sep 2025 18:03:55 +0200 Subject: [PATCH 0557/1075] Don't deprecate the 'pool' option yet We'll prefer the new 'max_connections' name for documentation etc, but unless the user is introducing a confusing situation by adding 'min_connections', we don't need to force config churn during upgrade. --- .../database_configurations/hash_config.rb | 19 ++++++++++++--- .../hash_config_test.rb | 24 +++++++++++++++---- 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/activerecord/lib/active_record/database_configurations/hash_config.rb b/activerecord/lib/active_record/database_configurations/hash_config.rb index e2f6f9ba3c98b..9282a44fd02b4 100644 --- a/activerecord/lib/active_record/database_configurations/hash_config.rb +++ b/activerecord/lib/active_record/database_configurations/hash_config.rb @@ -38,9 +38,7 @@ class HashConfig < DatabaseConfig def initialize(env_name, name, configuration_hash) super(env_name, name) @configuration_hash = configuration_hash.symbolize_keys.freeze - ActiveRecord.deprecator.warn(<<~MSG) if @configuration_hash[:pool] - The pool option is deprecated and will be removed in Rails 8.2. Use max_connections instead. - MSG + validate_configuration! end # Determines whether a database configuration is for a replica / readonly @@ -209,6 +207,21 @@ def default_reaping_frequency # meet other configured timeouts. [20, idle_timeout, max_age, keepalive].compact.min end + + def validate_configuration! + if configuration_hash[:pool] && configuration_hash[:max_connections] + pool_val = configuration_hash[:pool].to_i + max_conn_val = configuration_hash[:max_connections].to_i + + if pool_val != max_conn_val + raise "Ambiguous configuration: 'pool' (#{pool_val}) and 'max_connections' (#{max_conn_val}) are set to different values. Prefer just 'max_connections'." + end + end + + if configuration_hash[:pool] && configuration_hash[:min_connections] + raise "Ambiguous configuration: when setting 'min_connections', use 'max_connections' instead of 'pool'." + end + end end end end diff --git a/activerecord/test/cases/database_configurations/hash_config_test.rb b/activerecord/test/cases/database_configurations/hash_config_test.rb index 50cc545cbf06e..f92525c5a807a 100644 --- a/activerecord/test/cases/database_configurations/hash_config_test.rb +++ b/activerecord/test/cases/database_configurations/hash_config_test.rb @@ -5,11 +5,8 @@ module ActiveRecord class DatabaseConfigurations class HashConfigTest < ActiveRecord::TestCase - def test_pool_config_raises_deprecation - config = nil - assert_deprecated ActiveRecord.deprecator do - config = HashConfig.new("default_env", "primary", pool: 6, adapter: "abstract") - end + def test_pool_config_works_without_deprecation + config = HashConfig.new("default_env", "primary", pool: 6, adapter: "abstract") assert_equal 6, config.max_connections end @@ -20,6 +17,23 @@ def test_pool_is_deprecated end end + def test_raises_when_pool_and_max_connections_have_different_values + assert_raises(RuntimeError, match: /Ambiguous configuration.*pool.*6.*max_connections.*10/) do + HashConfig.new("default_env", "primary", pool: 6, max_connections: 10, adapter: "abstract") + end + end + + def test_allows_pool_and_max_connections_when_same_value + config = HashConfig.new("default_env", "primary", pool: 6, max_connections: 6, adapter: "abstract") + assert_equal 6, config.max_connections + end + + def test_raises_when_pool_and_min_connections_are_set + assert_raises(RuntimeError, match: /Ambiguous configuration.*min_connections.*max_connections.*instead/) do + HashConfig.new("default_env", "primary", pool: 6, min_connections: 2, adapter: "abstract") + end + end + def test_max_age_default_when_nil config = HashConfig.new("default_env", "primary", max_age: nil, adapter: "abstract") assert_equal Float::INFINITY, config.max_age From ab70c5e80b1bfdf560b8fa7a94724c0dafdb1619 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Wed, 3 Sep 2025 08:15:12 +0000 Subject: [PATCH 0558/1075] Cleanup up the 8.1 release notes Those were 8.0 notes. --- guides/source/8_1_release_notes.md | 148 ----------------------------- 1 file changed, 148 deletions(-) diff --git a/guides/source/8_1_release_notes.md b/guides/source/8_1_release_notes.md index 78f63318a70ee..fca7f2972deed 100644 --- a/guides/source/8_1_release_notes.md +++ b/guides/source/8_1_release_notes.md @@ -28,14 +28,6 @@ Please refer to the [Changelog][railties] for detailed changes. ### Removals -* Remove deprecated `Rails::Generators::Testing::Behaviour`. - -* Remove deprecated `Rails.application.secrets`. - -* Remove deprecated `Rails.config.enable_dependency_loading`. - -* Remove deprecated `find_cmd_and_exec` console helper. - ### Deprecations ### Notable changes @@ -58,22 +50,8 @@ Please refer to the [Changelog][action-pack] for detailed changes. ### Removals -* Remove deprecated constant `ActionDispatch::IllegalStateError`. - -* Remove deprecated constant `AbstractController::Helpers::MissingHelperError`. - -* Remove deprecated comparison between `ActionController::Parameters` and `Hash`. - -* Remove deprecated `Rails.application.config.action_dispatch.return_only_request_media_type_on_content_type`. - -* Remove deprecated `speaker`, `vibrate`, and `vr` permissions policy directives. - -* Remove deprecated support to set `Rails.application.config.action_dispatch.show_exceptions` to `true` and `false`. - ### Deprecations -* Deprecate `Rails.application.config.action_controller.allow_deprecated_parameters_hash_equality`. - ### Notable changes Action View @@ -83,12 +61,8 @@ Please refer to the [Changelog][action-view] for detailed changes. ### Removals -* Remove deprecated `@rails/ujs` in favor of `Turbo`. - ### Deprecations -* Deprecate passing content to void elements when using `tag.br` type tag builders. - ### Notable changes Action Mailer @@ -98,10 +72,6 @@ Please refer to the [Changelog][action-mailer] for detailed changes. ### Removals -* Remove deprecated `config.action_mailer.preview_path`. - -* Remove deprecated params via `:args` for `assert_enqueued_email_with`. - ### Deprecations ### Notable changes @@ -113,73 +83,8 @@ Please refer to the [Changelog][active-record] for detailed changes. ### Removals -* Remove deprecated `Rails.application.config.active_record.suppress_multiple_database_warning`. - -* Remove deprecated support to call `alias_attribute` with non-existent attribute names. - -* Remove deprecated `name` argument from `ActiveRecord::Base.remove_connection`. - -* Remove deprecated `ActiveRecord::Base.clear_active_connections!`. - -* Remove deprecated `ActiveRecord::Base.clear_reloadable_connections!`. - -* Remove deprecated `ActiveRecord::Base.clear_all_connections!`. - -* Remove deprecated `ActiveRecord::Base.flush_idle_connections!`. - -* Remove deprecated `ActiveRecord::ActiveJobRequiredError`. - -* Remove deprecated support to define `explain` in the connection adapter with 2 arguments. - -* Remove deprecated `ActiveRecord::LogSubscriber.runtime` method. - -* Remove deprecated `ActiveRecord::LogSubscriber.runtime=` method. - -* Remove deprecated `ActiveRecord::LogSubscriber.reset_runtime` method. - -* Remove deprecated `ActiveRecord::Migration.check_pending` method. - -* Remove deprecated support to passing `SchemaMigration` and `InternalMetadata` classes as arguments to - `ActiveRecord::MigrationContext`. - -* Remove deprecated behavior to support referring to a singular association by its plural name. - -* Remove deprecated `TestFixtures.fixture_path`. - -* Remove deprecated support to `ActiveRecord::Base#read_attribute(:id)` to return the custom primary key value. - -* Remove deprecated support to passing coder and class as second argument to `serialize`. - -* Remove deprecated `#all_foreign_keys_valid?` from database adapters. - -* Remove deprecated `ActiveRecord::ConnectionAdapters::SchemaCache.load_from`. - -* Remove deprecated `ActiveRecord::ConnectionAdapters::SchemaCache#data_sources`. - -* Remove deprecated `#all_connection_pools`. - -* Remove deprecated support to apply `#connection_pool_list`, `#active_connections?`, `#clear_active_connections!`, - `#clear_reloadable_connections!`, `#clear_all_connections!` and `#flush_idle_connections!` to the connections pools - for the current role when the `role` argument isn't provided. - -* Remove deprecated `ActiveRecord::ConnectionAdapters::ConnectionPool#connection_klass`. - -* Remove deprecated `#quote_bound_value`. - -* Remove deprecated support to quote `ActiveSupport::Duration`. - -* Remove deprecated support to pass `deferrable: true` to `add_foreign_key`. - -* Remove deprecated support to pass `rewhere` to `ActiveRecord::Relation#merge`. - -* Remove deprecated behavior that would rollback a transaction block when exited using `return`, `break` or `throw`. - ### Deprecations -* Deprecate `Rails.application.config.active_record.allow_deprecated_singular_associations_name` - -* Deprecate `Rails.application.config.active_record.commit_transaction_on_non_local_return` - ### Notable changes Active Storage @@ -189,10 +94,6 @@ Please refer to the [Changelog][active-storage] for detailed changes. ### Removals -* Remove deprecated `config.active_storage.replace_on_assign_to_many`. - -* Remove deprecated `config.active_storage.silence_invalid_content_types_warning`. - ### Deprecations ### Notable changes @@ -215,49 +116,8 @@ Please refer to the [Changelog][active-support] for detailed changes. ### Removals -* Remove deprecated `ActiveSupport::Notifications::Event#children` and `ActiveSupport::Notifications::Event#parent_of?`. - -* Remove deprecated support to call the following methods without passing a deprecator: - - - `deprecate` - - `deprecate_constant` - - `ActiveSupport::Deprecation::DeprecatedObjectProxy.new` - - `ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy.new` - - `ActiveSupport::Deprecation::DeprecatedConstantProxy.new` - - `assert_deprecated` - - `assert_not_deprecated` - - `collect_deprecations` - -* Remove deprecated `ActiveSupport::Deprecation` delegation to instance. - -* Remove deprecated `SafeBuffer#clone_empty`. - -* Remove deprecated `#to_default_s` from `Array`, `Date`, `DateTime` and `Time`. - -* Remove deprecated `:pool_size` and `:pool_timeout` options for the cache storage. - -* Remove deprecated support for `config.active_support.cache_format_version = 6.1`. - -* Remove deprecated constants `ActiveSupport::LogSubscriber::CLEAR` and `ActiveSupport::LogSubscriber::BOLD`. - -* Remove deprecated support to bolding log text with positional boolean in `ActiveSupport::LogSubscriber#color`. - -* Remove deprecated `config.active_support.disable_to_s_conversion`. - -* Remove deprecated `config.active_support.remove_deprecated_time_with_zone_name`. - -* Remove deprecated `config.active_support.use_rfc4122_namespaced_uuids`. - -* Remove deprecated support to passing `Dalli::Client` instances to `MemCacheStore`. - -* Remove deprecated support for the pre-Ruby 2.4 behavior of `to_time` returning a `Time` object with local timezone. - ### Deprecations -* Deprecate `config.active_support.to_time_preserves_timezone`. - -* Deprecate `DateAndTime::Compatibility.preserve_timezone`. - ### Notable changes Active Job @@ -267,16 +127,8 @@ Please refer to the [Changelog][active-job] for detailed changes. ### Removals -* Remove deprecated primitive serializer for `BigDecimal` arguments. - -* Remove deprecated support to set numeric values to `scheduled_at` attribute. - -* Remove deprecated `:exponentially_longer` value for the `:wait` in `retry_on`. - ### Deprecations -* Deprecate `Rails.application.config.active_job.use_big_decimal_serialize`. - ### Notable changes Action Text From 303b8f7f9b018689377ac06f8988c0d8f5e6029a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Wed, 3 Sep 2025 08:18:43 +0000 Subject: [PATCH 0559/1075] Remove deprecated support to skipping over leading brackets in parameter names in the parameter parser Before: ```ruby ActionDispatch::ParamBuilder.from_query_string("[foo]=bar") # => { "foo" => "bar" } ActionDispatch::ParamBuilder.from_query_string("[foo][bar]=baz") # => { "foo" => { "bar" => "baz" } } ``` After: ```ruby ActionDispatch::ParamBuilder.from_query_string("[foo]=bar") # => { "[foo]" => "bar" } ActionDispatch::ParamBuilder.from_query_string("[foo][bar]=baz") # => { "[foo]" => { "bar" => "baz" } } ``` --- Gemfile.lock | 10 ++-- actionpack/CHANGELOG.md | 22 ++++++++ .../lib/action_dispatch/http/param_builder.rb | 51 +++++++++---------- actionpack/lib/action_dispatch/railtie.rb | 4 +- .../test/dispatch/param_builder_test.rb | 42 +-------------- guides/source/8_1_release_notes.md | 18 +++++++ 6 files changed, 73 insertions(+), 74 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index d8a670c296caf..19f46c67e9001 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -191,7 +191,7 @@ GEM childprocess (5.1.0) logger (~> 1.5) concurrent-ruby (1.3.5) - connection_pool (2.5.3) + connection_pool (2.5.4) crack (1.0.0) bigdecimal rexml @@ -398,7 +398,7 @@ GEM msgpack (1.7.5) multi_json (1.15.0) multipart-post (2.4.1) - mustermann (3.0.3) + mustermann (3.0.4) ruby2_keywords (~> 0.0.1) mutex_m (0.3.0) mysql2 (0.5.6) @@ -460,7 +460,7 @@ GEM pg (>= 1.1, < 2.0) raabro (1.4.0) racc (1.8.1) - rack (3.1.16) + rack (3.2.1) rack-cache (1.17.0) rack (>= 0.4) rack-protection (4.1.1) @@ -492,7 +492,7 @@ GEM redcarpet (3.6.1) redis (5.4.1) redis-client (>= 0.22.0) - redis-client (0.25.1) + redis-client (0.25.2) connection_pool redis-namespace (1.11.0) redis (>= 4) @@ -689,7 +689,7 @@ GEM thruster (0.1.10-arm64-darwin) thruster (0.1.10-x86_64-darwin) thruster (0.1.10-x86_64-linux) - tilt (2.6.0) + tilt (2.6.1) timeout (0.4.3) tomlrb (2.0.3) trailblazer-option (0.1.2) diff --git a/actionpack/CHANGELOG.md b/actionpack/CHANGELOG.md index ce28bab635d13..6e1fb51e90132 100644 --- a/actionpack/CHANGELOG.md +++ b/actionpack/CHANGELOG.md @@ -1,3 +1,25 @@ +* Remove deprecated support to skipping over leading brackets in parameter names in the parameter parser. + + Before: + + ```ruby + ActionDispatch::ParamBuilder.from_query_string("[foo]=bar") # => { "foo" => "bar" } + ActionDispatch::ParamBuilder.from_query_string("[foo][bar]=baz") # => { "foo" => { "bar" => "baz" } } + ``` + + After: + + ```ruby + ActionDispatch::ParamBuilder.from_query_string("[foo]=bar") # => { "[foo]" => "bar" } + ActionDispatch::ParamBuilder.from_query_string("[foo][bar]=baz") # => { "[foo]" => { "bar" => "baz" } } + ``` + + *Rafael Mendonça França* + +* Deprecate `Rails.application.config.action_dispatch.ignore_leading_brackets`. + + *Rafael Mendonça França* + * Raise `ActionController::TooManyRequests` error from `ActionController::RateLimiting` Requests that exceed the rate limit raise an `ActionController::TooManyRequests` error. diff --git a/actionpack/lib/action_dispatch/http/param_builder.rb b/actionpack/lib/action_dispatch/http/param_builder.rb index 9361c47f0475c..6e8a9e1281293 100644 --- a/actionpack/lib/action_dispatch/http/param_builder.rb +++ b/actionpack/lib/action_dispatch/http/param_builder.rb @@ -16,15 +16,27 @@ def initialize(param_depth_limit) @param_depth_limit = param_depth_limit end - cattr_accessor :ignore_leading_brackets - - LEADING_BRACKETS_COMPAT = defined?(::Rack::RELEASE) && ::Rack::RELEASE.to_s.start_with?("2.") - cattr_accessor :default self.default = make_default(100) class << self delegate :from_query_string, :from_pairs, :from_hash, to: :default + + def ignore_leading_brackets + ActionDispatch.deprecator.warn <<~MSG + ActionDispatch::ParamBuilder.ignore_leading_brackets is deprecated and have no effect and will be removed in Rails 8.2. + MSG + + @ignore_leading_brackets + end + + def ignore_leading_brackets=(value) + ActionDispatch.deprecator.warn <<~MSG + ActionDispatch::ParamBuilder.ignore_leading_brackets is deprecated and have no effect and will be removed in Rails 8.2. + MSG + + @ignore_leading_brackets = value + end end def from_query_string(qs, separator: nil, encoding_template: nil) @@ -69,30 +81,15 @@ def store_nested_param(params, name, v, depth, encoding_template = nil) # nil name, treat same as empty string (required by tests) k = after = "" elsif depth == 0 - if ignore_leading_brackets || (ignore_leading_brackets.nil? && LEADING_BRACKETS_COMPAT) - # Rack 2 compatible behavior, ignore leading brackets - if name =~ /\A[\[\]]*([^\[\]]+)\]*/ - k = $1 - after = $' || "" - - if !ignore_leading_brackets && (k != $& || !after.empty? && !after.start_with?("[")) - ActionDispatch.deprecator.warn("Skipping over leading brackets in parameter name #{name.inspect} is deprecated and will parse differently in Rails 8.1 or Rack 3.0.") - end - else - k = name - after = "" - end + # Start of parsing, don't treat [] or [ at start of string specially + if start = name.index("[", 1) + # Start of parameter nesting, use part before brackets as key + k = name[0, start] + after = name[start, name.length] else - # Start of parsing, don't treat [] or [ at start of string specially - if start = name.index("[", 1) - # Start of parameter nesting, use part before brackets as key - k = name[0, start] - after = name[start, name.length] - else - # Plain parameter with no nesting - k = name - after = "" - end + # Plain parameter with no nesting + k = name + after = "" end elsif name.start_with?("[]") # Array nesting diff --git a/actionpack/lib/action_dispatch/railtie.rb b/actionpack/lib/action_dispatch/railtie.rb index 71f48c1a32830..85519c6eab1d1 100644 --- a/actionpack/lib/action_dispatch/railtie.rb +++ b/actionpack/lib/action_dispatch/railtie.rb @@ -59,7 +59,9 @@ class Railtie < Rails::Railtie # :nodoc: ActionDispatch::Http::URL.domain_extractor = app.config.action_dispatch.domain_extractor end - ActionDispatch::ParamBuilder.ignore_leading_brackets = app.config.action_dispatch.ignore_leading_brackets + unless app.config.action_dispatch.ignore_leading_brackets.nil? + ActionDispatch::ParamBuilder.ignore_leading_brackets = app.config.action_dispatch.ignore_leading_brackets + end ActionDispatch::QueryParser.strict_query_string_separator = app.config.action_dispatch.strict_query_string_separator ActiveSupport.on_load(:action_dispatch_request) do diff --git a/actionpack/test/dispatch/param_builder_test.rb b/actionpack/test/dispatch/param_builder_test.rb index ae496a12d1080..544c4ef70d4fc 100644 --- a/actionpack/test/dispatch/param_builder_test.rb +++ b/actionpack/test/dispatch/param_builder_test.rb @@ -21,51 +21,11 @@ class ParamBuilderTest < ActiveSupport::TestCase assert_instance_of ActiveSupport::HashWithIndifferentAccess, result[:foo] end - if ::Rack::RELEASE.start_with?("2.") - test "(rack 2) defaults to ignoring leading bracket" do - assert_deprecated(ActionDispatch.deprecator) do - result = ActionDispatch::ParamBuilder.from_query_string("[foo]=bar") - assert_equal({ "foo" => "bar" }, result) - end - - assert_deprecated(ActionDispatch.deprecator) do - result = ActionDispatch::ParamBuilder.from_query_string("[foo][bar]=baz") - assert_equal({ "foo" => { "bar" => "baz" } }, result) - end - end - else - test "(rack 3) defaults to retaining leading bracket" do - result = ActionDispatch::ParamBuilder.from_query_string("[foo]=bar") - assert_equal({ "[foo]" => "bar" }, result) - - result = ActionDispatch::ParamBuilder.from_query_string("[foo][bar]=baz") - assert_equal({ "[foo]" => { "bar" => "baz" } }, result) - end - end - - test "configured for strict brackets" do - previous_brackets = ActionDispatch::ParamBuilder.ignore_leading_brackets - ActionDispatch::ParamBuilder.ignore_leading_brackets = false - + test "retaining leading bracket" do result = ActionDispatch::ParamBuilder.from_query_string("[foo]=bar") assert_equal({ "[foo]" => "bar" }, result) result = ActionDispatch::ParamBuilder.from_query_string("[foo][bar]=baz") assert_equal({ "[foo]" => { "bar" => "baz" } }, result) - ensure - ActionDispatch::ParamBuilder.ignore_leading_brackets = previous_brackets - end - - test "configured for ignoring leading brackets" do - previous_brackets = ActionDispatch::ParamBuilder.ignore_leading_brackets - ActionDispatch::ParamBuilder.ignore_leading_brackets = true - - result = ActionDispatch::ParamBuilder.from_query_string("[foo]=bar") - assert_equal({ "foo" => "bar" }, result) - - result = ActionDispatch::ParamBuilder.from_query_string("[foo][bar]=baz") - assert_equal({ "foo" => { "bar" => "baz" } }, result) - ensure - ActionDispatch::ParamBuilder.ignore_leading_brackets = previous_brackets end end diff --git a/guides/source/8_1_release_notes.md b/guides/source/8_1_release_notes.md index fca7f2972deed..f435ee8f92d34 100644 --- a/guides/source/8_1_release_notes.md +++ b/guides/source/8_1_release_notes.md @@ -50,8 +50,26 @@ Please refer to the [Changelog][action-pack] for detailed changes. ### Removals +* Remove deprecated support to skipping over leading brackets in parameter names in the parameter parser. + + Before: + + ```ruby + ActionDispatch::ParamBuilder.from_query_string("[foo]=bar") # => { "foo" => "bar" } + ActionDispatch::ParamBuilder.from_query_string("[foo][bar]=baz") # => { "foo" => { "bar" => "baz" } } + ``` + + After: + + ```ruby + ActionDispatch::ParamBuilder.from_query_string("[foo]=bar") # => { "[foo]" => "bar" } + ActionDispatch::ParamBuilder.from_query_string("[foo][bar]=baz") # => { "[foo]" => { "bar" => "baz" } } + ``` + ### Deprecations +* Deprecate `Rails.application.config.action_dispatch.ignore_leading_brackets`. + ### Notable changes Action View From 25e4b067726b22580dc18b4728e339fe0c1b27da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Wed, 3 Sep 2025 08:42:35 +0000 Subject: [PATCH 0560/1075] Remove deprecated support for using semicolons as a query string separator Before: ```ruby ActionDispatch::QueryParser.each_pair("foo=bar;baz=quux").to_a # => [["foo", "bar"], ["baz", "quux"]] ``` After: ```ruby ActionDispatch::QueryParser.each_pair("foo=bar;baz=quux").to_a # => [["foo", "bar;baz=quux"]] ``` --- actionpack/CHANGELOG.md | 18 ++++++++++++ .../lib/action_dispatch/http/query_parser.rb | 22 ++++++++------- actionpack/lib/action_dispatch/railtie.rb | 4 ++- actionpack/test/dispatch/query_parser_test.rb | 28 ++----------------- guides/source/8_1_release_notes.md | 16 +++++++++++ 5 files changed, 51 insertions(+), 37 deletions(-) diff --git a/actionpack/CHANGELOG.md b/actionpack/CHANGELOG.md index 6e1fb51e90132..9f0c270f4c53b 100644 --- a/actionpack/CHANGELOG.md +++ b/actionpack/CHANGELOG.md @@ -1,3 +1,21 @@ +* Remove deprecated support for using semicolons as a query string separator. + + Before: + + ```ruby + ActionDispatch::QueryParser.each_pair("foo=bar;baz=quux").to_a + # => [["foo", "bar"], ["baz", "quux"]] + ``` + + After: + + ```ruby + ActionDispatch::QueryParser.each_pair("foo=bar;baz=quux").to_a + # => [["foo", "bar;baz=quux"]] + ``` + + *Rafael Mendonça França* + * Remove deprecated support to skipping over leading brackets in parameter names in the parameter parser. Before: diff --git a/actionpack/lib/action_dispatch/http/query_parser.rb b/actionpack/lib/action_dispatch/http/query_parser.rb index 55488b6170858..6afdb64434fc0 100644 --- a/actionpack/lib/action_dispatch/http/query_parser.rb +++ b/actionpack/lib/action_dispatch/http/query_parser.rb @@ -6,12 +6,21 @@ module ActionDispatch class QueryParser DEFAULT_SEP = /& */n - COMPAT_SEP = /[&;] */n COMMON_SEP = { ";" => /; */n, ";," => /[;,] */n, "&" => /& */n, "&;" => /[&;] */n } - cattr_accessor :strict_query_string_separator + def self.strict_query_string_separator + ActionDispatch.deprecator.warn <<~MSG + The `strict_query_string_separator` configuration is deprecated have no effect and will be removed in Rails 8.2. + MSG + @strict_query_string_separator + end - SEMICOLON_COMPAT = defined?(::Rack::QueryParser::DEFAULT_SEP) && ::Rack::QueryParser::DEFAULT_SEP.to_s.include?(";") + def self.strict_query_string_separator=(value) + ActionDispatch.deprecator.warn <<~MSG + The `strict_query_string_separator` configuration is deprecated have no effect and will be removed in Rails 8.2. + MSG + @strict_query_string_separator = value + end #-- # Note this departs from WHATWG's specified parsing algorithm by @@ -25,13 +34,6 @@ def self.each_pair(s, separator = nil) splitter = if separator COMMON_SEP[separator] || /[#{separator}] */n - elsif strict_query_string_separator - DEFAULT_SEP - elsif SEMICOLON_COMPAT && s.include?(";") - if strict_query_string_separator.nil? - ActionDispatch.deprecator.warn("Using semicolon as a query string separator is deprecated and will not be supported in Rails 8.1 or Rack 3.0. Use `&` instead.") - end - COMPAT_SEP else DEFAULT_SEP end diff --git a/actionpack/lib/action_dispatch/railtie.rb b/actionpack/lib/action_dispatch/railtie.rb index 85519c6eab1d1..1ad32b7c64e82 100644 --- a/actionpack/lib/action_dispatch/railtie.rb +++ b/actionpack/lib/action_dispatch/railtie.rb @@ -62,7 +62,9 @@ class Railtie < Rails::Railtie # :nodoc: unless app.config.action_dispatch.ignore_leading_brackets.nil? ActionDispatch::ParamBuilder.ignore_leading_brackets = app.config.action_dispatch.ignore_leading_brackets end - ActionDispatch::QueryParser.strict_query_string_separator = app.config.action_dispatch.strict_query_string_separator + unless app.config.action_dispatch.strict_query_string_separator.nil? + ActionDispatch::QueryParser.strict_query_string_separator = app.config.action_dispatch.strict_query_string_separator + end ActiveSupport.on_load(:action_dispatch_request) do self.ignore_accept_header = app.config.action_dispatch.ignore_accept_header diff --git a/actionpack/test/dispatch/query_parser_test.rb b/actionpack/test/dispatch/query_parser_test.rb index f300dc9920392..02a1e25238bfa 100644 --- a/actionpack/test/dispatch/query_parser_test.rb +++ b/actionpack/test/dispatch/query_parser_test.rb @@ -23,32 +23,8 @@ class QueryParserTest < ActiveSupport::TestCase assert_equal [["a", "aa"], ["b", "bb"], ["c", "cc"]], parsed_pairs("a=aa&b=bb;c=cc", "&;") end - if ::Rack::RELEASE.start_with?("2.") - test "(rack 2) defaults to mixed separators" do - assert_deprecated(ActionDispatch.deprecator) do - assert_equal [["a", "aa"], ["b", "bb"], ["c", "cc"]], parsed_pairs("a=aa&b=bb;c=cc") - end - end - else - test "(rack 3) defaults to ampersand separator only" do - assert_equal [["a", "aa"], ["b", "bb;c=cc"]], parsed_pairs("a=aa&b=bb;c=cc") - end - end - - test "configured for strict separator" do - previous_separator = ActionDispatch::QueryParser.strict_query_string_separator - ActionDispatch::QueryParser.strict_query_string_separator = true - assert_equal [["a", "aa"], ["b", "bb;c=cc"]], parsed_pairs("a=aa&b=bb;c=cc", "&") - ensure - ActionDispatch::QueryParser.strict_query_string_separator = previous_separator - end - - test "configured for mixed separator" do - previous_separator = ActionDispatch::QueryParser.strict_query_string_separator - ActionDispatch::QueryParser.strict_query_string_separator = false - assert_equal [["a", "aa"], ["b", "bb"], ["c", "cc"]], parsed_pairs("a=aa&b=bb;c=cc", "&;") - ensure - ActionDispatch::QueryParser.strict_query_string_separator = previous_separator + test "defaults to ampersand separator only" do + assert_equal [["a", "aa"], ["b", "bb;c=cc"]], parsed_pairs("a=aa&b=bb;c=cc") end private diff --git a/guides/source/8_1_release_notes.md b/guides/source/8_1_release_notes.md index f435ee8f92d34..b8a4c1e7ea8d2 100644 --- a/guides/source/8_1_release_notes.md +++ b/guides/source/8_1_release_notes.md @@ -66,6 +66,22 @@ Please refer to the [Changelog][action-pack] for detailed changes. ActionDispatch::ParamBuilder.from_query_string("[foo][bar]=baz") # => { "[foo]" => { "bar" => "baz" } } ``` +* Remove deprecated support for using semicolons as a query string separator. + + Before: + + ```ruby + ActionDispatch::QueryParser.each_pair("foo=bar;baz=quux").to_a + # => [["foo", "bar"], ["baz", "quux"]] + ``` + + After: + + ```ruby + ActionDispatch::QueryParser.each_pair("foo=bar;baz=quux").to_a + # => [["foo", "bar;baz=quux"]] + ``` + ### Deprecations * Deprecate `Rails.application.config.action_dispatch.ignore_leading_brackets`. From 5405dbc894de3be054a910475ad7530dd98f06c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Wed, 3 Sep 2025 08:45:44 +0000 Subject: [PATCH 0561/1075] Tell when the deprecated code will be removed --- actionpack/lib/action_dispatch/routing/mapper.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/actionpack/lib/action_dispatch/routing/mapper.rb b/actionpack/lib/action_dispatch/routing/mapper.rb index 6c2fa455d1c04..27fed5e4997b7 100644 --- a/actionpack/lib/action_dispatch/routing/mapper.rb +++ b/actionpack/lib/action_dispatch/routing/mapper.rb @@ -667,7 +667,7 @@ def has_named_route?(name) def assign_deprecated_option(deprecated_options, key, method_name) if (deprecated_value = deprecated_options.delete(key)) ActionDispatch.deprecator.warn(<<~MSG.squish) - #{method_name} received a hash argument #{key}. Please use a keyword instead. + #{method_name} received a hash argument #{key}. Please use a keyword instead. Support to hash argument will be removed in Rails 8.2. MSG deprecated_value end @@ -676,7 +676,7 @@ def assign_deprecated_option(deprecated_options, key, method_name) def assign_deprecated_options(deprecated_options, options, method_name) deprecated_options.each do |key, value| ActionDispatch.deprecator.warn(<<~MSG.squish) - #{method_name} received a hash argument #{key}. Please use a keyword instead. + #{method_name} received a hash argument #{key}. Please use a keyword instead. Support to hash argument will be removed in Rails 8.2. MSG options[key] = value end From d1f523dbfb8b1c1d8ebf79f3ddc6bf809f4e8c32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Wed, 3 Sep 2025 08:54:11 +0000 Subject: [PATCH 0562/1075] Remove deprecated support to a route to multiple paths --- actionpack/CHANGELOG.md | 4 ++++ .../lib/action_dispatch/routing/mapper.rb | 7 ++----- actionpack/test/dispatch/routing_test.rb | 19 ++----------------- guides/source/8_1_release_notes.md | 2 ++ 4 files changed, 10 insertions(+), 22 deletions(-) diff --git a/actionpack/CHANGELOG.md b/actionpack/CHANGELOG.md index 9f0c270f4c53b..e4349214875fd 100644 --- a/actionpack/CHANGELOG.md +++ b/actionpack/CHANGELOG.md @@ -1,3 +1,7 @@ +* Remove deprecated support to a route to multiple paths. + + *Rafael Mendonça França* + * Remove deprecated support for using semicolons as a query string separator. Before: diff --git a/actionpack/lib/action_dispatch/routing/mapper.rb b/actionpack/lib/action_dispatch/routing/mapper.rb index 27fed5e4997b7..9b0ed34e821b4 100644 --- a/actionpack/lib/action_dispatch/routing/mapper.rb +++ b/actionpack/lib/action_dispatch/routing/mapper.rb @@ -1833,7 +1833,7 @@ def draw(name) # [match](rdoc-ref:Base#match). # # match 'path', to: 'controller#action', via: :post - # match 'path', 'otherpath', on: :member, via: :get + # match 'otherpath', on: :member, via: :get def match(*path_or_actions, as: DEFAULT, via: nil, to: nil, controller: nil, action: nil, on: nil, defaults: nil, constraints: nil, anchor: nil, format: nil, path: nil, internal: nil, **mapping, &block) if path_or_actions.grep(Hash).any? && (deprecated_options = path_or_actions.extract_options!) as = assign_deprecated_option(deprecated_options, :as, :match) if deprecated_options.key?(:as) @@ -1851,10 +1851,7 @@ def match(*path_or_actions, as: DEFAULT, via: nil, to: nil, controller: nil, act assign_deprecated_options(deprecated_options, mapping, :match) end - ActionDispatch.deprecator.warn(<<-MSG.squish) if path_or_actions.count > 1 - Mapping a route with multiple paths is deprecated and - will be removed in Rails 8.1. Please use multiple method calls instead. - MSG + raise ArgumentError, "Wrong number of arguments (expect 1, got #{path_or_actions.count})" if path_or_actions.count > 1 if path_or_actions.none? && mapping.any? hash_path, hash_to = mapping.find { |key, _| key.is_a?(String) } diff --git a/actionpack/test/dispatch/routing_test.rb b/actionpack/test/dispatch/routing_test.rb index 8ff2d8e229e9e..11570c53e07da 100644 --- a/actionpack/test/dispatch/routing_test.rb +++ b/actionpack/test/dispatch/routing_test.rb @@ -1549,20 +1549,11 @@ def test_index end def test_match_with_many_paths_containing_a_slash - assert_deprecated(ActionDispatch.deprecator) do + assert_raises(ArgumentError) do draw do get "get/first", "get/second", "get/third", to: "get#show" end end - - get "/get/first" - assert_equal "get#show", @response.body - - get "/get/second" - assert_equal "get#show", @response.body - - get "/get/third" - assert_equal "get#show", @response.body end def test_match_shorthand_with_no_scope @@ -1588,19 +1579,13 @@ def test_match_shorthand_inside_namespace end def test_match_shorthand_with_multiple_paths_inside_namespace - assert_deprecated(ActionDispatch.deprecator) do + assert_raises(ArgumentError) do draw do namespace :proposals do put "activate", "inactivate" end end end - - put "/proposals/activate" - assert_equal "proposals#activate", @response.body - - put "/proposals/inactivate" - assert_equal "proposals#inactivate", @response.body end def test_match_shorthand_inside_namespace_with_controller diff --git a/guides/source/8_1_release_notes.md b/guides/source/8_1_release_notes.md index b8a4c1e7ea8d2..90157cae72d7b 100644 --- a/guides/source/8_1_release_notes.md +++ b/guides/source/8_1_release_notes.md @@ -82,6 +82,8 @@ Please refer to the [Changelog][action-pack] for detailed changes. # => [["foo", "bar;baz=quux"]] ``` +* Remove deprecated support to a route to multiple paths. + ### Deprecations * Deprecate `Rails.application.config.action_dispatch.ignore_leading_brackets`. From 52e39a9dba1abf40b371f369572c842a267b8699 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Wed, 3 Sep 2025 10:03:05 +0000 Subject: [PATCH 0563/1075] Bump dynamic segment deprecation to 9.0 This time we will really remove it. --- actionpack/lib/action_dispatch/routing/route_set.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/actionpack/lib/action_dispatch/routing/route_set.rb b/actionpack/lib/action_dispatch/routing/route_set.rb index a52c5d7089e4e..a8bea98432708 100644 --- a/actionpack/lib/action_dispatch/routing/route_set.rb +++ b/actionpack/lib/action_dispatch/routing/route_set.rb @@ -657,14 +657,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 8.1. + will be removed in Rails 9.0. 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 8.1. + will be removed in Rails 9.0. MSG end From f9e51b7f84ed45f4b5cef9114b9da9079de01541 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Wed, 3 Sep 2025 10:17:46 +0000 Subject: [PATCH 0564/1075] Remove support to set `ActiveJob::Base.enqueue_after_transaction_commit` to `:never`, `:always` and `:default`. --- activejob/CHANGELOG.md | 8 ++++++ .../enqueue_after_transaction_commit.rb | 27 +------------------ activejob/lib/active_job/railtie.rb | 20 +------------- guides/source/8_1_release_notes.md | 4 +++ .../application/active_job_railtie_test.rb | 4 +-- .../test/application/configuration_test.rb | 4 +-- 6 files changed, 18 insertions(+), 49 deletions(-) diff --git a/activejob/CHANGELOG.md b/activejob/CHANGELOG.md index 16cbbf41bd9ed..97aa7c1e7a235 100644 --- a/activejob/CHANGELOG.md +++ b/activejob/CHANGELOG.md @@ -1,3 +1,11 @@ +* Remove support to set `ActiveJob::Base.enqueue_after_transaction_commit` to `:never`, `:always` and `:default`. + + *Rafael Mendonça França* + +* Remove deprecated `Rails.application.config.active_job.enqueue_after_transaction_commit`. + + *Rafael Mendonça França* + * `ActiveJob::Serializers::ObjectSerializers#klass` method is now public. Custom Active Job serializers must have a public `#klass` method too. diff --git a/activejob/lib/active_job/enqueue_after_transaction_commit.rb b/activejob/lib/active_job/enqueue_after_transaction_commit.rb index 50c19d92493f4..b07416795450b 100644 --- a/activejob/lib/active_job/enqueue_after_transaction_commit.rb +++ b/activejob/lib/active_job/enqueue_after_transaction_commit.rb @@ -4,32 +4,7 @@ module ActiveJob module EnqueueAfterTransactionCommit # :nodoc: private def raw_enqueue - enqueue_after_transaction_commit = self.class.enqueue_after_transaction_commit - - after_transaction = case self.class.enqueue_after_transaction_commit - when :always - ActiveJob.deprecator.warn(<<~MSG.squish) - Setting `#{self.class.name}.enqueue_after_transaction_commit = :always` is deprecated and will be removed in Rails 8.1. - Set to `true` to always enqueue the job after the transaction is committed. - MSG - true - when :never - ActiveJob.deprecator.warn(<<~MSG.squish) - Setting `#{self.class.name}.enqueue_after_transaction_commit = :never` is deprecated and will be removed in Rails 8.1. - Set to `false` to never enqueue the job after the transaction is committed. - MSG - false - when :default - ActiveJob.deprecator.warn(<<~MSG.squish) - Setting `#{self.class.name}.enqueue_after_transaction_commit = :default` is deprecated and will be removed in Rails 8.1. - Set to `false` to never enqueue the job after the transaction is committed. - MSG - false - else - enqueue_after_transaction_commit - end - - if after_transaction + if self.class.enqueue_after_transaction_commit self.successfully_enqueued = true ActiveRecord.after_all_transactions_commit do self.successfully_enqueued = false diff --git a/activejob/lib/active_job/railtie.rb b/activejob/lib/active_job/railtie.rb index fc57af28e42a8..81b67e1956207 100644 --- a/activejob/lib/active_job/railtie.rb +++ b/activejob/lib/active_job/railtie.rb @@ -29,25 +29,6 @@ class Railtie < Rails::Railtie # :nodoc: ActiveSupport.on_load(:active_job) do ActiveSupport.on_load(:active_record) do ActiveJob::Base.include EnqueueAfterTransactionCommit - - if app.config.active_job.key?(:enqueue_after_transaction_commit) - ActiveJob.deprecator.warn(<<~MSG.squish) - `config.active_job.enqueue_after_transaction_commit` is deprecated and will be removed in Rails 8.1. - This configuration can still be set on individual jobs using `self.enqueue_after_transaction_commit=`, - but due the nature of this behavior, it is not recommended to be set globally. - MSG - - value = case app.config.active_job.enqueue_after_transaction_commit - when :always - true - when :never - false - else - false - end - - ActiveJob::Base.enqueue_after_transaction_commit = value - end end end end @@ -78,6 +59,7 @@ class Railtie < Rails::Railtie # :nodoc: options = options.except( :log_query_tags_around_perform, :custom_serializers, + # This config can't be applied globally, so we need to remove otherwise it will be applied to `ActiveJob::Base`. :enqueue_after_transaction_commit ) diff --git a/guides/source/8_1_release_notes.md b/guides/source/8_1_release_notes.md index 90157cae72d7b..51ae467e1071d 100644 --- a/guides/source/8_1_release_notes.md +++ b/guides/source/8_1_release_notes.md @@ -163,6 +163,10 @@ Please refer to the [Changelog][active-job] for detailed changes. ### Removals +* Remove support to set `ActiveJob::Base.enqueue_after_transaction_commit` to `:never`, `:always` and `:default`. + +* Remove deprecated `Rails.application.config.active_job.enqueue_after_transaction_commit`. + ### Deprecations ### Notable changes diff --git a/railties/test/application/active_job_railtie_test.rb b/railties/test/application/active_job_railtie_test.rb index b656deb0404ed..d63e5db9a5b1a 100644 --- a/railties/test/application/active_job_railtie_test.rb +++ b/railties/test/application/active_job_railtie_test.rb @@ -14,11 +14,11 @@ class ActiveJobRailtieTest < ActiveSupport::TestCase app_file "app/jobs/foo_job.rb", <<-RUBY class FooJob < ActiveJob::Base - self.enqueue_after_transaction_commit = :never + self.enqueue_after_transaction_commit = false end RUBY - assert_equal ":never", rails("runner", "p FooJob.enqueue_after_transaction_commit").strip + assert_equal "false", rails("runner", "p FooJob.enqueue_after_transaction_commit").strip end end end diff --git a/railties/test/application/configuration_test.rb b/railties/test/application/configuration_test.rb index 6a262785a12fb..286a0edbff765 100644 --- a/railties/test/application/configuration_test.rb +++ b/railties/test/application/configuration_test.rb @@ -3273,7 +3273,7 @@ def klass test "config.active_job.enqueue_after_transaction_commit is deprecated" do app_file "config/initializers/enqueue_after_transaction_commit.rb", <<-RUBY - Rails.application.config.active_job.enqueue_after_transaction_commit = :always + Rails.application.config.active_job.enqueue_after_transaction_commit = true RUBY app "production" @@ -3282,7 +3282,7 @@ def klass ActiveRecord::Base end - assert_equal true, ActiveJob::Base.enqueue_after_transaction_commit + assert_equal false, ActiveJob::Base.enqueue_after_transaction_commit end test "active record job queue is set" do From 634f90b883ef0e4af5ba32bba7ca55a7efbe4701 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Wed, 3 Sep 2025 10:21:56 +0000 Subject: [PATCH 0565/1075] Remove deprecated internal `SuckerPunch` adapter in favor of the adapter included with the `sucker_punch` gem. --- Gemfile | 1 - Gemfile.lock | 3 - Rakefile | 2 +- activejob/CHANGELOG.md | 4 ++ activejob/Rakefile | 2 +- .../queue_adapters/sucker_punch_adapter.rb | 56 ------------------- activejob/test/adapters/sucker_punch.rb | 4 -- activejob/test/cases/adapter_test.rb | 27 --------- activejob/test/cases/test_case_test.rb | 2 - activejob/test/integration/queuing_test.rb | 2 +- .../integration/adapters/sucker_punch.rb | 8 --- guides/source/8_1_release_notes.md | 2 + 12 files changed, 9 insertions(+), 104 deletions(-) delete mode 100644 activejob/lib/active_job/queue_adapters/sucker_punch_adapter.rb delete mode 100644 activejob/test/adapters/sucker_punch.rb delete mode 100644 activejob/test/support/integration/adapters/sucker_punch.rb diff --git a/Gemfile b/Gemfile index e2c24fef654f3..cda90953c6c16 100644 --- a/Gemfile +++ b/Gemfile @@ -103,7 +103,6 @@ group :job do gem "resque", require: false gem "resque-scheduler", require: false gem "sidekiq", require: false - gem "sucker_punch", require: false gem "queue_classic", ">= 4.0.0", require: false, platforms: :ruby gem "sneakers", require: false gem "backburner", require: false diff --git a/Gemfile.lock b/Gemfile.lock index 19f46c67e9001..7afc7c53f5a75 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -670,8 +670,6 @@ GEM stimulus-rails (1.3.4) railties (>= 6.0.0) stringio (3.1.7) - sucker_punch (3.2.0) - concurrent-ruby (~> 1.0) tailwindcss-rails (3.2.0) railties (>= 7.0.0) tailwindcss-ruby @@ -818,7 +816,6 @@ DEPENDENCIES sqlite3 (>= 2.1) stackprof stimulus-rails - sucker_punch tailwindcss-rails terser (>= 1.1.4) thruster diff --git a/Rakefile b/Rakefile index 930b680504ebc..9b7e6717d0052 100644 --- a/Rakefile +++ b/Rakefile @@ -43,7 +43,7 @@ Releaser::FRAMEWORKS.each do |framework| end namespace :activejob do - activejob_adapters = %w(async inline queue_classic resque sidekiq sneakers sucker_punch backburner test) + activejob_adapters = %w(async inline queue_classic resque sidekiq sneakers backburner test) activejob_adapters.delete("queue_classic") if defined?(JRUBY_VERSION) desc "Run Active Job integration tests for all adapters" diff --git a/activejob/CHANGELOG.md b/activejob/CHANGELOG.md index 97aa7c1e7a235..8001eb2965827 100644 --- a/activejob/CHANGELOG.md +++ b/activejob/CHANGELOG.md @@ -1,3 +1,7 @@ +* Remove deprecated internal `SuckerPunch` adapter in favor of the adapter included with the `sucker_punch` gem. + + *Rafael Mendonça França* + * Remove support to set `ActiveJob::Base.enqueue_after_transaction_commit` to `:never`, `:always` and `:default`. *Rafael Mendonça França* diff --git a/activejob/Rakefile b/activejob/Rakefile index 32966ff4becca..2f8424e58bf5b 100644 --- a/activejob/Rakefile +++ b/activejob/Rakefile @@ -2,7 +2,7 @@ require "rake/testtask" -ACTIVEJOB_ADAPTERS = %w(async inline queue_classic resque sidekiq sneakers sucker_punch backburner test) +ACTIVEJOB_ADAPTERS = %w(async inline queue_classic resque sidekiq sneakers backburner test) ACTIVEJOB_ADAPTERS.delete("queue_classic") if defined?(JRUBY_VERSION) task default: :test diff --git a/activejob/lib/active_job/queue_adapters/sucker_punch_adapter.rb b/activejob/lib/active_job/queue_adapters/sucker_punch_adapter.rb deleted file mode 100644 index 82d5cc6dd779f..0000000000000 --- a/activejob/lib/active_job/queue_adapters/sucker_punch_adapter.rb +++ /dev/null @@ -1,56 +0,0 @@ -# frozen_string_literal: true - -require "sucker_punch" - -module ActiveJob - module QueueAdapters - # = Sucker Punch adapter for Active Job - # - # Sucker Punch is a single-process Ruby asynchronous processing library. - # This reduces the cost of hosting on a service like Heroku along - # with the memory footprint of having to maintain additional jobs if - # hosting on a dedicated server. All queues can run within a - # single application (e.g. \Rails, Sinatra, etc.) process. - # - # Read more about Sucker Punch {here}[https://github.com/brandonhilkert/sucker_punch]. - # - # To use Sucker Punch set the queue_adapter config to +:sucker_punch+. - # - # Rails.application.config.active_job.queue_adapter = :sucker_punch - class SuckerPunchAdapter < AbstractAdapter - def check_adapter - ActiveJob.deprecator.warn <<~MSG.squish - The `sucker_punch` adapter is deprecated and will be removed in Rails 8.1. - Please use the `async` adapter instead. - MSG - end - - def enqueue(job) # :nodoc: - if JobWrapper.respond_to?(:perform_async) - # sucker_punch 2.0 API - JobWrapper.perform_async job.serialize - else - # sucker_punch 1.0 API - JobWrapper.new.async.perform job.serialize - end - end - - def enqueue_at(job, timestamp) # :nodoc: - if JobWrapper.respond_to?(:perform_in) - delay = timestamp - Time.current.to_f - JobWrapper.perform_in delay, job.serialize - else - raise NotImplementedError, "sucker_punch 1.0 does not support `enqueue_at`. Please upgrade to version ~> 2.0.0 to enable this behavior." - end - end - - class JobWrapper # :nodoc: - include SuckerPunch::Job - - def perform(job_data) - Base.execute job_data - end - end - end - end -end diff --git a/activejob/test/adapters/sucker_punch.rb b/activejob/test/adapters/sucker_punch.rb deleted file mode 100644 index 04bad984d4beb..0000000000000 --- a/activejob/test/adapters/sucker_punch.rb +++ /dev/null @@ -1,4 +0,0 @@ -# frozen_string_literal: true - -require "sucker_punch/testing/inline" -ActiveJob::Base.queue_adapter = :sucker_punch diff --git a/activejob/test/cases/adapter_test.rb b/activejob/test/cases/adapter_test.rb index 5e58556812df3..2c179b2d38941 100644 --- a/activejob/test/cases/adapter_test.rb +++ b/activejob/test/cases/adapter_test.rb @@ -6,31 +6,4 @@ class AdapterTest < ActiveSupport::TestCase test "should load #{ENV['AJ_ADAPTER']} adapter" do assert_equal "active_job/queue_adapters/#{ENV['AJ_ADAPTER']}_adapter".classify, ActiveJob::Base.queue_adapter.class.name end - - if adapter_is?(:sucker_punch) - test "sucker_punch adapter should be deprecated" do - before_adapter = ActiveJob::Base.queue_adapter - - msg = <<~MSG.squish - The `sucker_punch` adapter is deprecated and will be removed in Rails 8.1. - Please use the `async` adapter instead. - MSG - assert_deprecated(msg, ActiveJob.deprecator) do - ActiveJob::Base.queue_adapter = :sucker_punch - end - - ensure - ActiveJob::Base.queue_adapter = before_adapter - end - - test "sucker_punch check_adapter should warn" do - msg = <<~MSG.squish - The `sucker_punch` adapter is deprecated and will be removed in Rails 8.1. - Please use the `async` adapter instead. - MSG - assert_deprecated(msg, ActiveJob.deprecator) do - ActiveJob::Base.queue_adapter.check_adapter - end - end - end end diff --git a/activejob/test/cases/test_case_test.rb b/activejob/test/cases/test_case_test.rb index 93333897b6dc3..d026b65dc3670 100644 --- a/activejob/test/cases/test_case_test.rb +++ b/activejob/test/cases/test_case_test.rb @@ -41,8 +41,6 @@ def test_set_test_adapter ActiveJob::QueueAdapters::SidekiqAdapter when :sneakers ActiveJob::QueueAdapters::SneakersAdapter - when :sucker_punch - ActiveJob::QueueAdapters::SuckerPunchAdapter else raise NotImplementedError.new end diff --git a/activejob/test/integration/queuing_test.rb b/activejob/test/integration/queuing_test.rb index 81751e665f786..28839ebaa3085 100644 --- a/activejob/test/integration/queuing_test.rb +++ b/activejob/test/integration/queuing_test.rb @@ -15,7 +15,7 @@ class QueuingTest < ActiveSupport::TestCase assert_job_executed end - unless adapter_is?(:inline, :async, :sucker_punch) + unless adapter_is?(:inline, :async) test "should not run jobs queued on a non-listening queue" do old_queue = TestJob.queue_name diff --git a/activejob/test/support/integration/adapters/sucker_punch.rb b/activejob/test/support/integration/adapters/sucker_punch.rb deleted file mode 100644 index 099d412c8fad4..0000000000000 --- a/activejob/test/support/integration/adapters/sucker_punch.rb +++ /dev/null @@ -1,8 +0,0 @@ -# frozen_string_literal: true - -module SuckerPunchJobsManager - def setup - ActiveJob::Base.queue_adapter = :sucker_punch - SuckerPunch.logger = nil - end -end diff --git a/guides/source/8_1_release_notes.md b/guides/source/8_1_release_notes.md index 51ae467e1071d..29bc669f03147 100644 --- a/guides/source/8_1_release_notes.md +++ b/guides/source/8_1_release_notes.md @@ -167,6 +167,8 @@ Please refer to the [Changelog][active-job] for detailed changes. * Remove deprecated `Rails.application.config.active_job.enqueue_after_transaction_commit`. +* Remove deprecated internal `SuckerPunch` adapter in favor of the adapter included with the `sucker_punch` gem. + ### Deprecations ### Notable changes From edfd665e4c517506331ade5aac3f04fc49651525 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Wed, 3 Sep 2025 10:25:18 +0000 Subject: [PATCH 0566/1075] Tell when deprecation will be removed --- .../lib/active_record/associations/collection_proxy.rb | 3 ++- activerecord/lib/active_record/signed_id.rb | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/activerecord/lib/active_record/associations/collection_proxy.rb b/activerecord/lib/active_record/associations/collection_proxy.rb index 9063ad6109d1e..d5f1294984982 100644 --- a/activerecord/lib/active_record/associations/collection_proxy.rb +++ b/activerecord/lib/active_record/associations/collection_proxy.rb @@ -1132,7 +1132,8 @@ def #{method}(...) association_name = @association.reflection.name ActiveRecord.deprecator.warn(<<~MSG) Using #{method} on association \#{association_name} with unpersisted records - is deprecated. The unpersisted records will be lost after this operation. + is deprecated and will be removed in Rails 8.2. + The unpersisted records will be lost after this operation. Please either persist your records first or store them separately before calling #{method}. MSG diff --git a/activerecord/lib/active_record/signed_id.rb b/activerecord/lib/active_record/signed_id.rb index 3d68d125653fd..595fee7e068e4 100644 --- a/activerecord/lib/active_record/signed_id.rb +++ b/activerecord/lib/active_record/signed_id.rb @@ -16,7 +16,7 @@ module SignedId module DeprecateSignedIdVerifierSecret def signed_id_verifier_secret=(secret) ActiveRecord.deprecator.warn(<<~MSG) - ActiveRecord::Base.signed_id_verifier_secret is deprecated and will be removed in the future. + ActiveRecord::Base.signed_id_verifier_secret is deprecated and will be removed in Rails 8.2. If the secret is model-specific, set Model.signed_id_verifier instead. From 54a1dc70f2f124c2e93813e2afc69bfcb6a3cfff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Wed, 3 Sep 2025 10:31:54 +0000 Subject: [PATCH 0567/1075] Remove deprecated `:retries` option for the SQLite3 adapter --- activerecord/CHANGELOG.md | 4 ++++ .../connection_adapters/sqlite3_adapter.rb | 10 +--------- guides/source/8_1_release_notes.md | 2 ++ 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/activerecord/CHANGELOG.md b/activerecord/CHANGELOG.md index 1c8e84cb030c9..592e3f3d777cd 100644 --- a/activerecord/CHANGELOG.md +++ b/activerecord/CHANGELOG.md @@ -1,3 +1,7 @@ +* Remove deprecated `:retries` option for the SQLite3 adapter. + + *Rafael Mendonça França* + * Introduce new database configuration options `keepalive`, `max_age`, and `min_connections` -- and rename `pool` to `max_connections` to match. diff --git a/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb b/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb index 97c39ed546c77..5bd517c0b4eb1 100644 --- a/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb @@ -850,18 +850,10 @@ def reconnect end def configure_connection - if @config[:timeout] && @config[:retries] - raise ArgumentError, "Cannot specify both timeout and retries arguments" - elsif @config[:timeout] + if @config[:timeout] timeout = self.class.type_cast_config_to_integer(@config[:timeout]) raise TypeError, "timeout must be integer, not #{timeout}" unless timeout.is_a?(Integer) @raw_connection.busy_handler_timeout = timeout - elsif @config[:retries] - ActiveRecord.deprecator.warn(<<~MSG) - The retries option is deprecated and will be removed in Rails 8.1. Use timeout instead. - MSG - retries = self.class.type_cast_config_to_integer(@config[:retries]) - raw_connection.busy_handler { |count| count <= retries } end super diff --git a/guides/source/8_1_release_notes.md b/guides/source/8_1_release_notes.md index 29bc669f03147..a4be3cc89c660 100644 --- a/guides/source/8_1_release_notes.md +++ b/guides/source/8_1_release_notes.md @@ -119,6 +119,8 @@ Please refer to the [Changelog][active-record] for detailed changes. ### Removals +* Remove deprecated `:retries` option for the SQLite3 adapter. + ### Deprecations ### Notable changes From 6edb68791dd5c02581d49558e652fcccaf870edd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Wed, 3 Sep 2025 12:02:34 +0000 Subject: [PATCH 0568/1075] Remove deprecated `:unsigned_float` and `:unsigned_decimal` column methods for MySQL --- activerecord/CHANGELOG.md | 4 ++++ .../connection_adapters/mysql/schema_definitions.rb | 5 +---- .../abstract_mysql_adapter/unsigned_type_test.rb | 11 ----------- guides/source/8_1_release_notes.md | 2 ++ 4 files changed, 7 insertions(+), 15 deletions(-) diff --git a/activerecord/CHANGELOG.md b/activerecord/CHANGELOG.md index 592e3f3d777cd..fa5745722e22d 100644 --- a/activerecord/CHANGELOG.md +++ b/activerecord/CHANGELOG.md @@ -1,3 +1,7 @@ +* Remove deprecated `:unsigned_float` and `:unsigned_decimal` column methods for MySQL. + + *Rafael Mendonça França* + * Remove deprecated `:retries` option for the SQLite3 adapter. *Rafael Mendonça França* diff --git a/activerecord/lib/active_record/connection_adapters/mysql/schema_definitions.rb b/activerecord/lib/active_record/connection_adapters/mysql/schema_definitions.rb index b4e522e73147f..1779bfeb22eed 100644 --- a/activerecord/lib/active_record/connection_adapters/mysql/schema_definitions.rb +++ b/activerecord/lib/active_record/connection_adapters/mysql/schema_definitions.rb @@ -44,10 +44,7 @@ module ColumnMethods # :call-seq: unsigned_bigint(*names, **options) define_column_methods :blob, :tinyblob, :mediumblob, :longblob, - :tinytext, :mediumtext, :longtext, :unsigned_integer, :unsigned_bigint, - :unsigned_float, :unsigned_decimal - - deprecate :unsigned_float, :unsigned_decimal, deprecator: ActiveRecord.deprecator + :tinytext, :mediumtext, :longtext, :unsigned_integer, :unsigned_bigint end # = Active Record MySQL Adapter \Index Definition diff --git a/activerecord/test/cases/adapters/abstract_mysql_adapter/unsigned_type_test.rb b/activerecord/test/cases/adapters/abstract_mysql_adapter/unsigned_type_test.rb index b602b398d726d..4fccda9603663 100644 --- a/activerecord/test/cases/adapters/abstract_mysql_adapter/unsigned_type_test.rb +++ b/activerecord/test/cases/adapters/abstract_mysql_adapter/unsigned_type_test.rb @@ -56,17 +56,6 @@ class UnsignedType < ActiveRecord::Base end end - test "deprecate unsigned_float and unsigned_decimal" do - @connection.change_table("unsigned_types") do |t| - assert_deprecated(ActiveRecord.deprecator) do - t.unsigned_float :unsigned_float_t - end - assert_deprecated(ActiveRecord.deprecator) do - t.unsigned_decimal :unsigned_decimal_t - end - end - end - test "schema dump includes unsigned option" do schema = dump_table_schema "unsigned_types" assert_match %r{t\.integer\s+"unsigned_integer",\s+unsigned: true$}, schema diff --git a/guides/source/8_1_release_notes.md b/guides/source/8_1_release_notes.md index a4be3cc89c660..8ed0417052ebf 100644 --- a/guides/source/8_1_release_notes.md +++ b/guides/source/8_1_release_notes.md @@ -121,6 +121,8 @@ Please refer to the [Changelog][active-record] for detailed changes. * Remove deprecated `:retries` option for the SQLite3 adapter. +* Remove deprecated `:unsigned_float` and `:unsigned_decimal` column methods for MySQL. + ### Deprecations ### Notable changes From f3e06703619cf13de78638580c175b0586bfa201 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Wed, 3 Sep 2025 12:17:55 +0000 Subject: [PATCH 0569/1075] Remove deprecated `:azure` storage service --- Gemfile | 1 - Gemfile.lock | 13 -- actionmailbox/test/dummy/config/storage.yml | 7 - actiontext/test/dummy/config/storage.yml | 7 - activestorage/CHANGELOG.md | 4 + activestorage/README.md | 2 +- activestorage/lib/active_storage/service.rb | 1 - .../service/azure_storage_service.rb | 201 ------------------ .../blobs/redirect_controller_test.rb | 11 - .../direct_uploads_controller_test.rb | 42 ---- .../redirect_controller_test.rb | 15 -- .../test/dummy/config/environments/test.rb | 6 - activestorage/test/dummy/config/storage.yml | 7 - .../azure_storage_public_service_test.rb | 47 ---- .../service/azure_storage_service_test.rb | 122 ----------- .../test/service/configurations.example.yml | 6 - .../test/service/configurations.yml.enc | Bin 2848 -> 2624 bytes .../test/service/configurator_test.rb | 18 -- guides/source/8_1_release_notes.md | 2 + guides/source/active_storage_overview.md | 46 +--- .../rails/app/templates/config/storage.yml.tt | 7 - 21 files changed, 10 insertions(+), 555 deletions(-) delete mode 100644 activestorage/lib/active_storage/service/azure_storage_service.rb delete mode 100644 activestorage/test/service/azure_storage_public_service_test.rb delete mode 100644 activestorage/test/service/azure_storage_service_test.rb diff --git a/Gemfile b/Gemfile index cda90953c6c16..0eafec781b2da 100644 --- a/Gemfile +++ b/Gemfile @@ -123,7 +123,6 @@ end group :storage do gem "aws-sdk-s3", require: false gem "google-cloud-storage", "~> 1.11", require: false - gem "azure-storage-blob", "~> 2.0", require: false gem "image_processing", "~> 1.2" end diff --git a/Gemfile.lock b/Gemfile.lock index 7afc7c53f5a75..945bb99694c1c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -146,14 +146,6 @@ GEM aws-sigv4 (~> 1.5) aws-sigv4 (1.12.1) aws-eventstream (~> 1, >= 1.0.2) - azure-storage-blob (2.0.3) - azure-storage-common (~> 2.0) - nokogiri (~> 1, >= 1.10.8) - azure-storage-common (2.0.4) - faraday (~> 1.0) - faraday_middleware (~> 1.0, >= 1.0.0.rc1) - net-http-persistent (~> 4.0) - nokogiri (~> 1, >= 1.10.8) backburner (1.6.1) beaneater (~> 1.0) concurrent-ruby (~> 1.0, >= 1.0.1) @@ -241,8 +233,6 @@ GEM faraday-patron (1.0.0) faraday-rack (1.0.0) faraday-retry (1.0.3) - faraday_middleware (1.2.1) - faraday (~> 1.0) ffi (1.17.1) ffi (1.17.1-aarch64-linux-gnu) ffi (1.17.1-aarch64-linux-musl) @@ -404,8 +394,6 @@ GEM mysql2 (0.5.6) net-http (0.6.0) uri - net-http-persistent (4.0.5) - connection_pool (~> 2.2) net-imap (0.5.5) date net-protocol @@ -748,7 +736,6 @@ PLATFORMS DEPENDENCIES aws-sdk-s3 aws-sdk-sns - azure-storage-blob (~> 2.0) backburner bcrypt (~> 3.1.11) bootsnap (>= 1.4.4) diff --git a/actionmailbox/test/dummy/config/storage.yml b/actionmailbox/test/dummy/config/storage.yml index c26dd89d229af..b8a59e9a53a4d 100644 --- a/actionmailbox/test/dummy/config/storage.yml +++ b/actionmailbox/test/dummy/config/storage.yml @@ -25,13 +25,6 @@ test_email: # credentials: <%= Rails.root.join("path/to/gcs.keyfile") %> # bucket: your_own_bucket-<%= Rails.env %> -# Use bin/rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key) -# microsoft: -# service: AzureStorage -# storage_account_name: your_account_name -# storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %> -# container: your_container_name-<%= Rails.env %> - # mirror: # service: Mirror # primary: local diff --git a/actiontext/test/dummy/config/storage.yml b/actiontext/test/dummy/config/storage.yml index 4942ab66948b7..927dc537c8a6c 100644 --- a/actiontext/test/dummy/config/storage.yml +++ b/actiontext/test/dummy/config/storage.yml @@ -21,13 +21,6 @@ local: # credentials: <%= Rails.root.join("path/to/gcs.keyfile") %> # bucket: your_own_bucket-<%= Rails.env %> -# Use bin/rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key) -# microsoft: -# service: AzureStorage -# storage_account_name: your_account_name -# storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %> -# container: your_container_name-<%= Rails.env %> - # mirror: # service: Mirror # primary: local diff --git a/activestorage/CHANGELOG.md b/activestorage/CHANGELOG.md index fd1bd790ef694..d386949f5661d 100644 --- a/activestorage/CHANGELOG.md +++ b/activestorage/CHANGELOG.md @@ -1,3 +1,7 @@ +* Remove deprecated `:azure` storage service. + + *Rafael Mendonça França* + * Remove unnecessary calls to the GCP metadata server. Calling Google::Auth.get_application_default triggers an explicit call to diff --git a/activestorage/README.md b/activestorage/README.md index 8ce31d7cc0e58..a72322caeb384 100644 --- a/activestorage/README.md +++ b/activestorage/README.md @@ -1,6 +1,6 @@ # Active Storage -Active Storage makes it simple to upload and reference files in cloud services like [Amazon S3](https://aws.amazon.com/s3/), [Google Cloud Storage](https://cloud.google.com/storage/docs/), or [Microsoft Azure Storage](https://azure.microsoft.com/en-us/services/storage/), and attach those files to Active Records. Supports having one main service and mirrors in other services for redundancy. It also provides a disk service for testing or local deployments, but the focus is on cloud storage. +Active Storage makes it simple to upload and reference files in cloud services like [Amazon S3](https://aws.amazon.com/s3/), or [Google Cloud Storage](https://cloud.google.com/storage/docs/), and attach those files to Active Records. Supports having one main service and mirrors in other services for redundancy. It also provides a disk service for testing or local deployments, but the focus is on cloud storage. Files can be uploaded from the server to the cloud or directly from the client to the cloud. diff --git a/activestorage/lib/active_storage/service.rb b/activestorage/lib/active_storage/service.rb index 518c68c46205e..85dc2b8da877f 100644 --- a/activestorage/lib/active_storage/service.rb +++ b/activestorage/lib/active_storage/service.rb @@ -15,7 +15,6 @@ module ActiveStorage # * +Disk+, to manage attachments saved directly on the hard drive. # * +GCS+, to manage attachments through Google Cloud Storage. # * +S3+, to manage attachments through Amazon S3. - # * +AzureStorage+, to manage attachments through Microsoft Azure Storage. # * +Mirror+, to be able to use several services to manage attachments. # # Inside a \Rails application, you can set-up your services through the diff --git a/activestorage/lib/active_storage/service/azure_storage_service.rb b/activestorage/lib/active_storage/service/azure_storage_service.rb deleted file mode 100644 index 72feb25a6e015..0000000000000 --- a/activestorage/lib/active_storage/service/azure_storage_service.rb +++ /dev/null @@ -1,201 +0,0 @@ -# frozen_string_literal: true - -gem "azure-storage-blob", ">= 2.0" - -require "active_support/core_ext/numeric/bytes" -require "azure/storage/blob" -require "azure/storage/common/core/auth/shared_access_signature" - -module ActiveStorage - # = Active Storage \Azure Storage \Service - # - # Wraps the Microsoft Azure Storage Blob Service as an Active Storage service. - # See ActiveStorage::Service for the generic API documentation that applies to all services. - class Service::AzureStorageService < Service - attr_reader :client, :container, :signer - - def initialize(storage_account_name:, storage_access_key:, container:, public: false, **options) - ActiveStorage.deprecator.warn <<~MSG.squish - `ActiveStorage::Service::AzureStorageService` is deprecated and will be - removed in Rails 8.1. - Please try the `azure-blob` gem instead. - This gem is not maintained by the Rails team, so please test your applications before deploying to production. - MSG - - @client = Azure::Storage::Blob::BlobService.create(storage_account_name: storage_account_name, storage_access_key: storage_access_key, **options) - @signer = Azure::Storage::Common::Core::Auth::SharedAccessSignature.new(storage_account_name, storage_access_key) - @container = container - @public = public - end - - def upload(key, io, checksum: nil, filename: nil, content_type: nil, disposition: nil, custom_metadata: {}, **) - instrument :upload, key: key, checksum: checksum do - handle_errors do - content_disposition = content_disposition_with(filename: filename, type: disposition) if disposition && filename - - client.create_block_blob(container, key, IO.try_convert(io) || io, content_md5: checksum, content_type: content_type, content_disposition: content_disposition, metadata: custom_metadata) - end - end - end - - def download(key, &block) - if block_given? - instrument :streaming_download, key: key do - stream(key, &block) - end - else - instrument :download, key: key do - handle_errors do - _, io = client.get_blob(container, key) - io.force_encoding(Encoding::BINARY) - end - end - end - end - - def download_chunk(key, range) - instrument :download_chunk, key: key, range: range do - handle_errors do - _, io = client.get_blob(container, key, start_range: range.begin, end_range: range.exclude_end? ? range.end - 1 : range.end) - io.force_encoding(Encoding::BINARY) - end - end - end - - def delete(key) - instrument :delete, key: key do - client.delete_blob(container, key) - rescue Azure::Core::Http::HTTPError => e - raise unless e.type == "BlobNotFound" - # Ignore files already deleted - end - end - - def delete_prefixed(prefix) - instrument :delete_prefixed, prefix: prefix do - marker = nil - - loop do - results = client.list_blobs(container, prefix: prefix, marker: marker) - - results.each do |blob| - client.delete_blob(container, blob.name) - end - - break unless marker = results.continuation_token.presence - end - end - end - - def exist?(key) - instrument :exist, key: key do |payload| - answer = blob_for(key).present? - payload[:exist] = answer - answer - end - end - - def url_for_direct_upload(key, expires_in:, content_type:, content_length:, checksum:, custom_metadata: {}) - instrument :url, key: key do |payload| - generated_url = signer.signed_uri( - uri_for(key), false, - service: "b", - permissions: "rw", - expiry: format_expiry(expires_in) - ).to_s - - payload[:url] = generated_url - - generated_url - end - end - - def headers_for_direct_upload(key, content_type:, checksum:, filename: nil, disposition: nil, custom_metadata: {}, **) - content_disposition = content_disposition_with(type: disposition, filename: filename) if filename - - { "Content-Type" => content_type, "Content-MD5" => checksum, "x-ms-blob-content-disposition" => content_disposition, "x-ms-blob-type" => "BlockBlob", **custom_metadata_headers(custom_metadata) } - end - - def compose(source_keys, destination_key, filename: nil, content_type: nil, disposition: nil, custom_metadata: {}) - content_disposition = content_disposition_with(type: disposition, filename: filename) if disposition && filename - - client.create_append_blob( - container, - destination_key, - content_type: content_type, - content_disposition: content_disposition, - metadata: custom_metadata, - ).tap do |blob| - source_keys.each do |source_key| - stream(source_key) do |chunk| - client.append_blob_block(container, blob.name, chunk) - end - end - end - end - - private - def private_url(key, expires_in:, filename:, disposition:, content_type:, **) - signer.signed_uri( - uri_for(key), false, - service: "b", - permissions: "r", - expiry: format_expiry(expires_in), - content_disposition: content_disposition_with(type: disposition, filename: filename), - content_type: content_type - ).to_s - end - - def public_url(key, **) - uri_for(key).to_s - end - - - def uri_for(key) - client.generate_uri("#{container}/#{key}") - end - - def blob_for(key) - client.get_blob_properties(container, key) - rescue Azure::Core::Http::HTTPError - false - end - - def format_expiry(expires_in) - expires_in ? Time.now.utc.advance(seconds: expires_in).iso8601 : nil - end - - # Reads the object for the given key in chunks, yielding each to the block. - def stream(key) - blob = blob_for(key) - - chunk_size = 5.megabytes - offset = 0 - - raise ActiveStorage::FileNotFoundError unless blob.present? - - while offset < blob.properties[:content_length] - _, chunk = client.get_blob(container, key, start_range: offset, end_range: offset + chunk_size - 1) - yield chunk.force_encoding(Encoding::BINARY) - offset += chunk_size - end - end - - def handle_errors - yield - rescue Azure::Core::Http::HTTPError => e - case e.type - when "BlobNotFound" - raise ActiveStorage::FileNotFoundError - when "Md5Mismatch" - raise ActiveStorage::IntegrityError - else - raise - end - end - - def custom_metadata_headers(metadata) - metadata.transform_keys { |key| "x-ms-meta-#{key}" } - end - end -end diff --git a/activestorage/test/controllers/blobs/redirect_controller_test.rb b/activestorage/test/controllers/blobs/redirect_controller_test.rb index e4543b9eb6bc3..ab9217d04643c 100644 --- a/activestorage/test/controllers/blobs/redirect_controller_test.rb +++ b/activestorage/test/controllers/blobs/redirect_controller_test.rb @@ -80,17 +80,6 @@ class ActiveStorage::Blobs::RedirectControllerWithOpenRedirectTest < ActionDispa end end - if SERVICE_CONFIGURATIONS[:azure] - test "showing existing blob stored in azure" do - with_raise_on_open_redirects(:azure) do - blob = create_file_blob filename: "racecar.jpg", service_name: :azure - - get rails_storage_redirect_url(blob) - assert_redirected_to(/racecar\.jpg/) - end - end - end - if SERVICE_CONFIGURATIONS[:gcs] test "showing existing blob stored in gcs" do with_raise_on_open_redirects(:gcs) do diff --git a/activestorage/test/controllers/direct_uploads_controller_test.rb b/activestorage/test/controllers/direct_uploads_controller_test.rb index 7bd151e694549..8cb01ed1049ff 100644 --- a/activestorage/test/controllers/direct_uploads_controller_test.rb +++ b/activestorage/test/controllers/direct_uploads_controller_test.rb @@ -92,48 +92,6 @@ class ActiveStorage::GCSDirectUploadsControllerTest < ActionDispatch::Integratio puts "Skipping GCS Direct Upload tests because no GCS configuration was supplied" end -if SERVICE_CONFIGURATIONS[:azure] - class ActiveStorage::AzureStorageDirectUploadsControllerTest < ActionDispatch::IntegrationTest - setup do - @config = SERVICE_CONFIGURATIONS[:azure] - - @old_service = ActiveStorage::Blob.service - ActiveStorage::Blob.service = ActiveStorage::Service.configure(:azure, SERVICE_CONFIGURATIONS) - end - - teardown do - ActiveStorage::Blob.service = @old_service - end - - test "creating new direct upload" do - checksum = ActiveStorage.checksum_implementation.base64digest("Hello") - metadata = { - "foo" => "bar", - "my_key_1" => "my_value_1", - "my_key_2" => "my_value_2", - "platform" => "my_platform", - "library_ID" => "12345" - } - - post rails_direct_uploads_url, params: { blob: { - filename: "hello.txt", byte_size: 6, checksum: checksum, content_type: "text/plain", metadata: metadata } } - - response.parsed_body.tap do |details| - assert_equal ActiveStorage::Blob.find(details["id"]), ActiveStorage::Blob.find_signed!(details["signed_id"]) - assert_equal "hello.txt", details["filename"] - assert_equal 6, details["byte_size"] - assert_equal checksum, details["checksum"] - assert_equal metadata, details["metadata"] - assert_equal "text/plain", details["content_type"] - assert_match %r{#{@config[:storage_account_name]}\.blob\.core\.windows\.net/#{@config[:container]}}, details["direct_upload"]["url"] - assert_equal({ "Content-Type" => "text/plain", "Content-MD5" => checksum, "x-ms-blob-content-disposition" => "inline; filename=\"hello.txt\"; filename*=UTF-8''hello.txt", "x-ms-blob-type" => "BlockBlob" }, details["direct_upload"]["headers"]) - end - end - end -else - puts "Skipping Azure Storage Direct Upload tests because no Azure Storage configuration was supplied" -end - class ActiveStorage::DiskDirectUploadsControllerTest < ActionDispatch::IntegrationTest test "creating new direct upload" do checksum = ActiveStorage.checksum_implementation.base64digest("Hello") diff --git a/activestorage/test/controllers/representations/redirect_controller_test.rb b/activestorage/test/controllers/representations/redirect_controller_test.rb index e4b0d2e613dcd..67fdd9c3d09c1 100644 --- a/activestorage/test/controllers/representations/redirect_controller_test.rb +++ b/activestorage/test/controllers/representations/redirect_controller_test.rb @@ -149,21 +149,6 @@ class ActiveStorage::Representations::RedirectControllerWithOpenRedirectTest < A end end - if SERVICE_CONFIGURATIONS[:azure] - test "showing existing variant stored in azure" do - with_raise_on_open_redirects(:azure) do - blob = create_file_blob filename: "racecar.jpg", service_name: :azure - - get rails_blob_representation_url( - filename: blob.filename, - signed_blob_id: blob.signed_id, - variation_key: ActiveStorage::Variation.encode(resize_to_limit: [100, 100])) - - assert_redirected_to(/racecar\.jpg/) - end - end - end - if SERVICE_CONFIGURATIONS[:gcs] test "showing existing variant stored in gcs" do with_raise_on_open_redirects(:gcs) do diff --git a/activestorage/test/dummy/config/environments/test.rb b/activestorage/test/dummy/config/environments/test.rb index 7286f46b43d3c..bdc6e053126bf 100644 --- a/activestorage/test/dummy/config/environments/test.rb +++ b/activestorage/test/dummy/config/environments/test.rb @@ -43,12 +43,6 @@ puts "Missing service configuration file in #{config_file}" {} end - # Azure service tests are currently failing on the main branch. - # We temporarily disable them while we get things working again. - if ENV["BUILDKITE"] - SERVICE_CONFIGURATIONS.delete(:azure) - SERVICE_CONFIGURATIONS.delete(:azure_public) - end config.active_storage.service_configurations = SERVICE_CONFIGURATIONS.merge( "local" => { "service" => "Disk", "root" => Dir.mktmpdir("active_storage_tests") }, diff --git a/activestorage/test/dummy/config/storage.yml b/activestorage/test/dummy/config/storage.yml index 4942ab66948b7..927dc537c8a6c 100644 --- a/activestorage/test/dummy/config/storage.yml +++ b/activestorage/test/dummy/config/storage.yml @@ -21,13 +21,6 @@ local: # credentials: <%= Rails.root.join("path/to/gcs.keyfile") %> # bucket: your_own_bucket-<%= Rails.env %> -# Use bin/rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key) -# microsoft: -# service: AzureStorage -# storage_account_name: your_account_name -# storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %> -# container: your_container_name-<%= Rails.env %> - # mirror: # service: Mirror # primary: local diff --git a/activestorage/test/service/azure_storage_public_service_test.rb b/activestorage/test/service/azure_storage_public_service_test.rb deleted file mode 100644 index ca8e932d68f44..0000000000000 --- a/activestorage/test/service/azure_storage_public_service_test.rb +++ /dev/null @@ -1,47 +0,0 @@ -# frozen_string_literal: true - -require "service/shared_service_tests" -require "uri" - -if SERVICE_CONFIGURATIONS[:azure_public] - class ActiveStorage::Service::AzureStoragePublicServiceTest < ActiveSupport::TestCase - SERVICE = ActiveStorage::Service.configure(:azure_public, SERVICE_CONFIGURATIONS) - - include ActiveStorage::Service::SharedServiceTests - - test "public URL generation" do - url = @service.url(@key, filename: ActiveStorage::Filename.new("avatar.png")) - - assert_match(/.*\.blob\.core\.windows\.net\/.*\/#{@key}/, url) - - response = Net::HTTP.get_response(URI(url)) - assert_equal "200", response.code - end - - test "direct upload" do - key = SecureRandom.base58(24) - data = "Something else entirely!" - checksum = ActiveStorage.checksum_implementation.base64digest(data) - content_type = "text/xml" - url = @service.url_for_direct_upload(key, expires_in: 5.minutes, content_type: content_type, content_length: data.size, checksum: checksum) - - uri = URI.parse url - request = Net::HTTP::Put.new uri.request_uri - request.body = data - @service.headers_for_direct_upload(key, checksum: checksum, content_type: content_type, filename: ActiveStorage::Filename.new("test.txt")).each do |k, v| - request.add_field k, v - end - Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http| - http.request request - end - - response = Net::HTTP.get_response(URI(@service.url(key))) - assert_equal "200", response.code - assert_equal data, response.body - ensure - @service.delete key - end - end -else - puts "Skipping Azure Storage Public Service tests because no Azure configuration was supplied" -end diff --git a/activestorage/test/service/azure_storage_service_test.rb b/activestorage/test/service/azure_storage_service_test.rb deleted file mode 100644 index 186cae3ae0651..0000000000000 --- a/activestorage/test/service/azure_storage_service_test.rb +++ /dev/null @@ -1,122 +0,0 @@ -# frozen_string_literal: true - -require "service/shared_service_tests" -require "uri" - -if SERVICE_CONFIGURATIONS[:azure] - class ActiveStorage::Service::AzureStorageServiceTest < ActiveSupport::TestCase - SERVICE = ActiveStorage::Service.configure(:azure, SERVICE_CONFIGURATIONS) - - include ActiveStorage::Service::SharedServiceTests - - test "direct upload with content type" do - key = SecureRandom.base58(24) - data = "Something else entirely!" - checksum = ActiveStorage.checksum_implementation.base64digest(data) - content_type = "text/xml" - url = @service.url_for_direct_upload(key, expires_in: 5.minutes, content_type: content_type, content_length: data.size, checksum: checksum) - - uri = URI.parse url - request = Net::HTTP::Put.new uri.request_uri - request.body = data - @service.headers_for_direct_upload(key, checksum: checksum, content_type: content_type, filename: ActiveStorage::Filename.new("test.txt")).each do |k, v| - request.add_field k, v - end - Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http| - http.request request - end - - assert_equal(content_type, @service.client.get_blob_properties(@service.container, key).properties[:content_type]) - ensure - @service.delete key - end - - test "direct upload with content disposition" do - key = SecureRandom.base58(24) - data = "Something else entirely!" - checksum = ActiveStorage.checksum_implementation.base64digest(data) - url = @service.url_for_direct_upload(key, expires_in: 5.minutes, content_type: "text/plain", content_length: data.size, checksum: checksum) - - uri = URI.parse url - request = Net::HTTP::Put.new uri.request_uri - request.body = data - @service.headers_for_direct_upload(key, checksum: checksum, content_type: "text/plain", filename: ActiveStorage::Filename.new("test.txt"), disposition: :attachment).each do |k, v| - request.add_field k, v - end - Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http| - http.request request - end - - assert_equal("attachment; filename=\"test.txt\"; filename*=UTF-8''test.txt", @service.client.get_blob_properties(@service.container, key).properties[:content_disposition]) - ensure - @service.delete key - end - - test "upload with content_type" do - key = SecureRandom.base58(24) - data = "Foobar" - - @service.upload(key, StringIO.new(data), checksum: ActiveStorage.checksum_implementation.base64digest(data), filename: ActiveStorage::Filename.new("test.txt"), content_type: "text/plain") - - url = @service.url(key, expires_in: 2.minutes, disposition: :attachment, content_type: nil, filename: ActiveStorage::Filename.new("test.html")) - response = Net::HTTP.get_response(URI(url)) - assert_equal "text/plain", response.content_type - assert_match(/attachment;.*test\.html/, response["Content-Disposition"]) - ensure - @service.delete key - end - - test "upload with content disposition" do - key = SecureRandom.base58(24) - data = "Foobar" - - @service.upload(key, StringIO.new(data), checksum: ActiveStorage.checksum_implementation.base64digest(data), filename: ActiveStorage::Filename.new("test.txt"), disposition: :inline) - - assert_equal("inline; filename=\"test.txt\"; filename*=UTF-8''test.txt", @service.client.get_blob_properties(@service.container, key).properties[:content_disposition]) - - url = @service.url(key, expires_in: 2.minutes, disposition: :attachment, content_type: nil, filename: ActiveStorage::Filename.new("test.html")) - response = Net::HTTP.get_response(URI(url)) - assert_match(/attachment;.*test\.html/, response["Content-Disposition"]) - ensure - @service.delete key - end - - test "upload with custom_metadata" do - key = SecureRandom.base58(24) - data = "Foobar" - - @service.upload(key, StringIO.new(data), checksum: ActiveStorage.checksum_implementation.base64digest(data), filename: ActiveStorage::Filename.new("test.txt"), custom_metadata: { "foo" => "baz" }) - url = @service.url(key, expires_in: 2.minutes, disposition: :inline, content_type: "text/html", filename: ActiveStorage::Filename.new("test.html")) - - response = Net::HTTP.get_response(URI(url)) - assert_equal("baz", response["x-ms-meta-foo"]) - ensure - @service.delete key - end - - test "signed URL generation" do - url = @service.url(@key, expires_in: 5.minutes, - disposition: :inline, filename: ActiveStorage::Filename.new("avatar.png"), content_type: "image/png") - - assert_match(/(\S+)&rscd=inline%3B\+filename%3D%22avatar\.png%22%3B\+filename\*%3DUTF-8%27%27avatar\.png&rsct=image%2Fpng/, url) - assert_match SERVICE_CONFIGURATIONS[:azure][:container], url - end - - test "uploading a tempfile" do - key = SecureRandom.base58(24) - data = "Something else entirely!" - - Tempfile.open do |file| - file.write(data) - file.rewind - @service.upload(key, file) - end - - assert_equal data, @service.download(key) - ensure - @service.delete(key) - end - end -else - puts "Skipping Azure Storage Service tests because no Azure configuration was supplied" -end diff --git a/activestorage/test/service/configurations.example.yml b/activestorage/test/service/configurations.example.yml index 48813f81052c6..6473082842cb7 100644 --- a/activestorage/test/service/configurations.example.yml +++ b/activestorage/test/service/configurations.example.yml @@ -23,9 +23,3 @@ # bucket: # iam: false # gsa_email: "foobar@baz.iam.gserviceaccount.com" -# -# azure: -# service: AzureStorage -# storage_account_name: "" -# storage_access_key: "" -# container: "" diff --git a/activestorage/test/service/configurations.yml.enc b/activestorage/test/service/configurations.yml.enc index 648924a562399ecaf95d8fcefd0cf9527d50e3e9..8d39117d2c3260b92d0145648a55f22916fdb049 100644 GIT binary patch delta 24 gcmZ1=c0go<0hho*Bc7A?A|NaBMAY$i>AK{2Yh(Pa#;w~S2F`IkB znPfiW3$}|BXqmi5T%iQ|#O3rR`M87L`@Y=0pCuNYn^U-z(CD!NflXUd$Z_W>EQ^Jg diff --git a/activestorage/test/service/configurator_test.rb b/activestorage/test/service/configurator_test.rb index 3e55406ee4c35..4e009174c78c4 100644 --- a/activestorage/test/service/configurator_test.rb +++ b/activestorage/test/service/configurator_test.rb @@ -33,22 +33,4 @@ class ActiveStorage::Service::ConfiguratorTest < ActiveSupport::TestCase configurator = ActiveStorage::Service::Configurator.new({}) assert_match(/#/, configurator.inspect) end - - test "azure service is deprecated" do - msg = <<~MSG.squish - `ActiveStorage::Service::AzureStorageService` is deprecated and will be - removed in Rails 8.1. - Please try the `azure-blob` gem instead. - This gem is not maintained by the Rails team, so please test your applications before deploying to production. - MSG - - assert_deprecated(msg, ActiveStorage.deprecator) do - ActiveStorage::Service::Configurator.build(:azure, azure: { - service: "AzureStorage", - storage_account_name: "test_account", - storage_access_key: Base64.encode64("test_access_key").strip, - container: "container" - }) - end - end end diff --git a/guides/source/8_1_release_notes.md b/guides/source/8_1_release_notes.md index 8ed0417052ebf..53fc60a54f61a 100644 --- a/guides/source/8_1_release_notes.md +++ b/guides/source/8_1_release_notes.md @@ -134,6 +134,8 @@ Please refer to the [Changelog][active-storage] for detailed changes. ### Removals +* Remove deprecated `:azure` storage service. + ### Deprecations ### Notable changes diff --git a/guides/source/active_storage_overview.md b/guides/source/active_storage_overview.md index 63462765d98f9..e25d7f528f647 100644 --- a/guides/source/active_storage_overview.md +++ b/guides/source/active_storage_overview.md @@ -23,7 +23,7 @@ What is Active Storage? ----------------------- Active Storage facilitates uploading files to a cloud storage service like -Amazon S3, Google Cloud Storage, or Microsoft Azure Storage and attaching those +Amazon S3, or Google Cloud Storage and attaching those files to Active Record objects. It comes with a local disk-based service for development and testing and supports mirroring files to subordinate services for backups and migrations. @@ -135,11 +135,6 @@ google: service: GCS # ... bucket: your_own_bucket-<%= Rails.env %> - -azure: - service: AzureStorage - # ... - container: your_container_name-<%= Rails.env %> ``` Continue reading for more information on the built-in service adapters (e.g. @@ -215,25 +210,6 @@ digitalocean: There are many other options available. You can check them in [AWS S3 Client](https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/S3/Client.html#initialize-instance_method) documentation. -### Microsoft Azure Storage Service - -Declare an Azure Storage service in `config/storage.yml`: - -```yaml -# Use bin/rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key) -azure: - service: AzureStorage - storage_account_name: your_account_name - storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %> - container: your_container_name-<%= Rails.env %> -``` - -Add the [`azure-storage-blob`](https://github.com/Azure/azure-storage-ruby) gem to your `Gemfile`: - -```ruby -gem "azure-storage-blob", "~> 2.0", require: false -``` - ### Google Cloud Storage Service Declare a Google Cloud Storage service in `config/storage.yml`: @@ -369,7 +345,7 @@ public_gcs: public: true ``` -Make sure your buckets are properly configured for public access. See docs on how to enable public read permissions for [Amazon S3](https://docs.aws.amazon.com/AmazonS3/latest/user-guide/block-public-access-bucket.html), [Google Cloud Storage](https://cloud.google.com/storage/docs/access-control/making-data-public#buckets), and [Microsoft Azure](https://docs.microsoft.com/en-us/azure/storage/blobs/storage-manage-access-to-resources#set-container-public-access-level-in-the-azure-portal) storage services. Amazon S3 additionally requires that you have the `s3:PutObjectAcl` permission. +Make sure your buckets are properly configured for public access. See docs on how to enable public read permissions for [Amazon S3](https://docs.aws.amazon.com/AmazonS3/latest/user-guide/block-public-access-bucket.html) and [Google Cloud Storage](https://cloud.google.com/storage/docs/access-control/making-data-public#buckets) storage services. Amazon S3 additionally requires that you have the `s3:PutObjectAcl` permission. When converting an existing application to use `public: true`, make sure to update every individual file in the bucket to be publicly-readable before switching over. @@ -1093,7 +1069,6 @@ To make direct uploads to a third-party service work, you’ll need to configure * [S3](https://docs.aws.amazon.com/AmazonS3/latest/userguide/enabling-cors-examples.html) * [Google Cloud Storage](https://cloud.google.com/storage/docs/configuring-cors) -* [Azure Storage](https://docs.microsoft.com/en-us/rest/api/storageservices/cross-origin-resource-sharing--cors--support-for-the-azure-storage-services) Take care to allow: @@ -1102,9 +1077,7 @@ Take care to allow: * The following headers: * `Content-Type` * `Content-MD5` - * `Content-Disposition` (except for Azure Storage) - * `x-ms-blob-content-disposition` (for Azure Storage only) - * `x-ms-blob-type` (for Azure Storage only) + * `Content-Disposition` * `Cache-Control` (for GCS, only if `cache_control` is set) No CORS configuration is required for the Disk service since it shares your app’s origin. @@ -1143,19 +1116,6 @@ No CORS configuration is required for the Disk service since it shares your app ] ``` -#### Example: Azure Storage CORS Configuration - -```xml - - - https://www.example.com - PUT - Content-Type, Content-MD5, x-ms-blob-content-disposition, x-ms-blob-type - 3600 - - -``` - ### Direct Upload JavaScript Events | Event name | Event target | Event data (`event.detail`) | Description | diff --git a/railties/lib/rails/generators/rails/app/templates/config/storage.yml.tt b/railties/lib/rails/generators/rails/app/templates/config/storage.yml.tt index 5e6bdfe07b05b..8369c83e8d3d0 100644 --- a/railties/lib/rails/generators/rails/app/templates/config/storage.yml.tt +++ b/railties/lib/rails/generators/rails/app/templates/config/storage.yml.tt @@ -21,13 +21,6 @@ local: # credentials: <%%= Rails.root.join("path/to/gcs.keyfile") %> # bucket: your_own_bucket-<%%= Rails.env %> -# Use bin/rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key) -# microsoft: -# service: AzureStorage -# storage_account_name: your_account_name -# storage_access_key: <%%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %> -# container: your_container_name-<%%= Rails.env %> - # mirror: # service: Mirror # primary: local From d9e3f0190b0026bfecc2d962f8f3cd6875e73987 Mon Sep 17 00:00:00 2001 From: fatkodima Date: Fri, 27 Sep 2024 01:43:16 +0300 Subject: [PATCH 0570/1075] Deprecate `sidekiq` as an adapter option --- activejob/CHANGELOG.md | 6 ++++++ .../active_job/queue_adapters/sidekiq_adapter.rb | 7 +++++++ activejob/test/cases/adapter_test.rb | 16 ++++++++++++++++ 3 files changed, 29 insertions(+) diff --git a/activejob/CHANGELOG.md b/activejob/CHANGELOG.md index 8001eb2965827..b31b04d3a00d5 100644 --- a/activejob/CHANGELOG.md +++ b/activejob/CHANGELOG.md @@ -1,3 +1,9 @@ +* Deprecate built-in `sidekiq` adapter. + + If you're using this adapter, upgrade to `sidekiq` 7.3.3 or later to use the `sidekiq` gem's adapter. + + *fatkodima* + * Remove deprecated internal `SuckerPunch` adapter in favor of the adapter included with the `sucker_punch` gem. *Rafael Mendonça França* diff --git a/activejob/lib/active_job/queue_adapters/sidekiq_adapter.rb b/activejob/lib/active_job/queue_adapters/sidekiq_adapter.rb index 2c1c7962dd80e..e73a5c0578027 100644 --- a/activejob/lib/active_job/queue_adapters/sidekiq_adapter.rb +++ b/activejob/lib/active_job/queue_adapters/sidekiq_adapter.rb @@ -30,6 +30,13 @@ def initialize(*) # :nodoc: end end + def check_adapter + ActiveJob.deprecator.warn <<~MSG.squish + The built-in `sidekiq` adapter is deprecated and will be removed in Rails 8.2. + Please upgrade `sidekiq` gem to version 7.3.3 or later to use the `sidekiq` gem's adapter. + MSG + end + def enqueue(job) # :nodoc: job.provider_job_id = JobWrapper.set( wrapped: job.class, diff --git a/activejob/test/cases/adapter_test.rb b/activejob/test/cases/adapter_test.rb index 2c179b2d38941..5a1c1f9281ad9 100644 --- a/activejob/test/cases/adapter_test.rb +++ b/activejob/test/cases/adapter_test.rb @@ -6,4 +6,20 @@ class AdapterTest < ActiveSupport::TestCase test "should load #{ENV['AJ_ADAPTER']} adapter" do assert_equal "active_job/queue_adapters/#{ENV['AJ_ADAPTER']}_adapter".classify, ActiveJob::Base.queue_adapter.class.name end + + if adapter_is?(:sidekiq) + test "sidekiq adapter should be deprecated" do + before_adapter = ActiveJob::Base.queue_adapter + + msg = <<~MSG.squish + The built-in `sidekiq` adapter is deprecated and will be removed in Rails 8.2. + Please upgrade `sidekiq` gem to version 7.3.3 or later to use the `sidekiq` gem's adapter. + MSG + assert_deprecated(msg, ActiveJob.deprecator) do + ActiveJob::Base.queue_adapter = :sidekiq + end + ensure + ActiveJob::Base.queue_adapter = before_adapter + end + end end From fd630d8ed2695478205f75dd57cf24c844143ff9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Thu, 4 Sep 2025 11:46:33 +0000 Subject: [PATCH 0571/1075] BigDecimal now works with floats without precision See https://github.com/ruby/bigdecimal/pull/314. --- Gemfile.lock | 2 +- activesupport/test/xml_mini_test.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 945bb99694c1c..457b217af0fd6 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -155,7 +155,7 @@ GEM bcrypt_pbkdf (1.1.1) beaneater (1.1.3) benchmark (0.4.1) - bigdecimal (3.2.2) + bigdecimal (3.2.3) bindex (0.8.1) bootsnap (1.18.4) msgpack (~> 1.2) diff --git a/activesupport/test/xml_mini_test.rb b/activesupport/test/xml_mini_test.rb index 90e5699dd7d75..56f5d87413b47 100644 --- a/activesupport/test/xml_mini_test.rb +++ b/activesupport/test/xml_mini_test.rb @@ -310,7 +310,7 @@ def test_decimal assert_equal 123.0, parser.call("123,003") assert_equal 0.0, parser.call("") assert_equal 123, parser.call(123) - assert_raises(ArgumentError) { parser.call(123.04) } + assert_equal BigDecimal("123.04"), parser.call(123.04) assert_raises(ArgumentError) { parser.call(Date.new(2013, 11, 12, 02, 11)) } end From 7df2bebf6b086b9d1f0c13fd8f854328ee0936a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Thu, 4 Sep 2025 11:54:59 +0000 Subject: [PATCH 0572/1075] Use released sdoc For now, we are going to keep the current version of the design. --- Gemfile | 2 +- Gemfile.lock | 34 +++++++++++++++++++++------------- 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/Gemfile b/Gemfile index 0eafec781b2da..dca418b3c10fa 100644 --- a/Gemfile +++ b/Gemfile @@ -64,7 +64,7 @@ group :mdl do end group :doc do - gem "sdoc", git: "https://github.com/rails/sdoc.git", branch: "main" + gem "sdoc" gem "rdoc", "< 6.10" gem "redcarpet", "~> 3.6.1", platforms: :ruby gem "w3c_validators", "~> 1.3.6" diff --git a/Gemfile.lock b/Gemfile.lock index 457b217af0fd6..e71311e27e232 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,13 +1,3 @@ -GIT - remote: https://github.com/rails/sdoc.git - revision: cd75e36ce2d1acb66734c1390ffe33aa05479380 - branch: main - specs: - sdoc (3.0.0.alpha) - nokogiri - rdoc (>= 5.0) - rouge - PATH remote: . specs: @@ -409,9 +399,25 @@ GEM net-protocol net-ssh (7.3.0) nio4r (2.7.4) - nokogiri (1.18.8) + nokogiri (1.18.9) mini_portile2 (~> 2.8.2) racc (~> 1.4) + nokogiri (1.18.9-aarch64-linux-gnu) + racc (~> 1.4) + nokogiri (1.18.9-aarch64-linux-musl) + racc (~> 1.4) + nokogiri (1.18.9-arm-linux-gnu) + racc (~> 1.4) + nokogiri (1.18.9-arm-linux-musl) + racc (~> 1.4) + nokogiri (1.18.9-arm64-darwin) + racc (~> 1.4) + nokogiri (1.18.9-x86_64-darwin) + racc (~> 1.4) + nokogiri (1.18.9-x86_64-linux-gnu) + racc (~> 1.4) + nokogiri (1.18.9-x86_64-linux-musl) + racc (~> 1.4) os (1.1.4) ostruct (0.6.1) parallel (1.26.3) @@ -503,7 +509,7 @@ GEM rufus-scheduler (~> 3.2, != 3.3) retriable (3.1.2) rexml (3.4.0) - rouge (4.5.1) + rouge (4.6.0) rubocop (1.79.2) json (~> 2.3) language_server-protocol (~> 3.17.0.2) @@ -570,6 +576,8 @@ GEM google-protobuf (~> 4.29) sass-embedded (1.83.4-x86_64-linux-musl) google-protobuf (~> 4.29) + sdoc (2.6.2) + rdoc (>= 5.0) securerandom (0.4.1) selenium-webdriver (4.32.0) base64 (~> 0.2) @@ -792,7 +800,7 @@ DEPENDENCIES rubocop-rails rubocop-rails-omakase rubyzip (~> 2.0) - sdoc! + sdoc selenium-webdriver (>= 4.20.0) sidekiq sneakers From 87fbda5b1e5d383a6b794f55820860c24e3ff326 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Thu, 4 Sep 2025 11:59:02 +0000 Subject: [PATCH 0573/1075] Update note in the README about new Active Job adapters --- activejob/README.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/activejob/README.md b/activejob/README.md index 20e0e0e152103..2d5c6332e2169 100644 --- a/activejob/README.md +++ b/activejob/README.md @@ -89,11 +89,9 @@ Active Job has built-in adapters for multiple queuing backends (Sidekiq, Resque, Delayed Job and others). To get an up-to-date list of the adapters see the API Documentation for [ActiveJob::QueueAdapters](https://api.rubyonrails.org/classes/ActiveJob/QueueAdapters.html). -**Please note:** We are not accepting pull requests for new adapters. We -encourage library authors to provide an ActiveJob adapter as part of -their gem, or as a stand-alone gem. For discussion about this see the -following PRs: [23311](https://github.com/rails/rails/issues/23311#issuecomment-176275718), -[21406](https://github.com/rails/rails/pull/21406#issuecomment-138813484), and [#32285](https://github.com/rails/rails/pull/32285). +**Please note:** We are not accepting pull requests for new adapters, and we are +actively extracting the current adapters. We encourage library authors to provide +an Active Job adapter as part of their gem, or as a stand-alone gem. ## Continuations From 80827ca7ff33ca3026f2c74fe8249ccc6f513318 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Thu, 4 Sep 2025 12:28:41 +0000 Subject: [PATCH 0574/1075] Preparing for 8.1.0.beta1 release --- Gemfile.lock | 106 ++--- RAILS_VERSION | 2 +- actioncable/CHANGELOG.md | 2 + actioncable/lib/action_cable/gem_version.rb | 2 +- actioncable/package.json | 2 +- actionmailbox/CHANGELOG.md | 2 + .../lib/action_mailbox/gem_version.rb | 2 +- actionmailer/CHANGELOG.md | 2 + actionmailer/lib/action_mailer/gem_version.rb | 2 +- actionpack/CHANGELOG.md | 2 + actionpack/lib/action_pack/gem_version.rb | 2 +- actiontext/CHANGELOG.md | 2 + actiontext/lib/action_text/gem_version.rb | 2 +- actiontext/package.json | 2 +- actionview/CHANGELOG.md | 2 + actionview/lib/action_view/gem_version.rb | 2 +- activejob/CHANGELOG.md | 2 + activejob/lib/active_job/gem_version.rb | 2 +- activemodel/CHANGELOG.md | 2 + activemodel/lib/active_model/gem_version.rb | 2 +- activerecord/CHANGELOG.md | 2 + activerecord/lib/active_record/gem_version.rb | 2 +- activestorage/CHANGELOG.md | 2 + .../lib/active_storage/gem_version.rb | 2 +- activestorage/package.json | 2 +- activesupport/CHANGELOG.md | 2 + .../lib/active_support/gem_version.rb | 2 +- guides/CHANGELOG.md | 2 + railties/CHANGELOG.md | 2 + railties/lib/rails/gem_version.rb | 2 +- yarn.lock | 448 ++++++++++-------- 31 files changed, 351 insertions(+), 261 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index e71311e27e232..8c57570c51ff1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,29 +1,29 @@ PATH remote: . specs: - actioncable (8.1.0.alpha) - actionpack (= 8.1.0.alpha) - activesupport (= 8.1.0.alpha) + actioncable (8.1.0.beta1) + actionpack (= 8.1.0.beta1) + activesupport (= 8.1.0.beta1) nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) - actionmailbox (8.1.0.alpha) - actionpack (= 8.1.0.alpha) - activejob (= 8.1.0.alpha) - activerecord (= 8.1.0.alpha) - activestorage (= 8.1.0.alpha) - activesupport (= 8.1.0.alpha) + actionmailbox (8.1.0.beta1) + actionpack (= 8.1.0.beta1) + activejob (= 8.1.0.beta1) + activerecord (= 8.1.0.beta1) + activestorage (= 8.1.0.beta1) + activesupport (= 8.1.0.beta1) mail (>= 2.8.0) - actionmailer (8.1.0.alpha) - actionpack (= 8.1.0.alpha) - actionview (= 8.1.0.alpha) - activejob (= 8.1.0.alpha) - activesupport (= 8.1.0.alpha) + actionmailer (8.1.0.beta1) + actionpack (= 8.1.0.beta1) + actionview (= 8.1.0.beta1) + activejob (= 8.1.0.beta1) + activesupport (= 8.1.0.beta1) mail (>= 2.8.0) rails-dom-testing (~> 2.2) - actionpack (8.1.0.alpha) - actionview (= 8.1.0.alpha) - activesupport (= 8.1.0.alpha) + actionpack (8.1.0.beta1) + actionview (= 8.1.0.beta1) + activesupport (= 8.1.0.beta1) nokogiri (>= 1.8.5) rack (>= 2.2.4) rack-session (>= 1.0.1) @@ -31,36 +31,36 @@ PATH rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) useragent (~> 0.16) - actiontext (8.1.0.alpha) + actiontext (8.1.0.beta1) action_text-trix (~> 2.1.15) - actionpack (= 8.1.0.alpha) - activerecord (= 8.1.0.alpha) - activestorage (= 8.1.0.alpha) - activesupport (= 8.1.0.alpha) + actionpack (= 8.1.0.beta1) + activerecord (= 8.1.0.beta1) + activestorage (= 8.1.0.beta1) + activesupport (= 8.1.0.beta1) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (8.1.0.alpha) - activesupport (= 8.1.0.alpha) + actionview (8.1.0.beta1) + activesupport (= 8.1.0.beta1) builder (~> 3.1) erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - activejob (8.1.0.alpha) - activesupport (= 8.1.0.alpha) + activejob (8.1.0.beta1) + activesupport (= 8.1.0.beta1) globalid (>= 0.3.6) - activemodel (8.1.0.alpha) - activesupport (= 8.1.0.alpha) - activerecord (8.1.0.alpha) - activemodel (= 8.1.0.alpha) - activesupport (= 8.1.0.alpha) + activemodel (8.1.0.beta1) + activesupport (= 8.1.0.beta1) + activerecord (8.1.0.beta1) + activemodel (= 8.1.0.beta1) + activesupport (= 8.1.0.beta1) timeout (>= 0.4.0) - activestorage (8.1.0.alpha) - actionpack (= 8.1.0.alpha) - activejob (= 8.1.0.alpha) - activerecord (= 8.1.0.alpha) - activesupport (= 8.1.0.alpha) + activestorage (8.1.0.beta1) + actionpack (= 8.1.0.beta1) + activejob (= 8.1.0.beta1) + activerecord (= 8.1.0.beta1) + activesupport (= 8.1.0.beta1) marcel (~> 1.0) - activesupport (8.1.0.alpha) + activesupport (8.1.0.beta1) base64 benchmark (>= 0.3) bigdecimal @@ -73,23 +73,23 @@ PATH securerandom (>= 0.3) tzinfo (~> 2.0, >= 2.0.5) uri (>= 0.13.1) - rails (8.1.0.alpha) - actioncable (= 8.1.0.alpha) - actionmailbox (= 8.1.0.alpha) - actionmailer (= 8.1.0.alpha) - actionpack (= 8.1.0.alpha) - actiontext (= 8.1.0.alpha) - actionview (= 8.1.0.alpha) - activejob (= 8.1.0.alpha) - activemodel (= 8.1.0.alpha) - activerecord (= 8.1.0.alpha) - activestorage (= 8.1.0.alpha) - activesupport (= 8.1.0.alpha) + rails (8.1.0.beta1) + actioncable (= 8.1.0.beta1) + actionmailbox (= 8.1.0.beta1) + actionmailer (= 8.1.0.beta1) + actionpack (= 8.1.0.beta1) + actiontext (= 8.1.0.beta1) + actionview (= 8.1.0.beta1) + activejob (= 8.1.0.beta1) + activemodel (= 8.1.0.beta1) + activerecord (= 8.1.0.beta1) + activestorage (= 8.1.0.beta1) + activesupport (= 8.1.0.beta1) bundler (>= 1.15.0) - railties (= 8.1.0.alpha) - railties (8.1.0.alpha) - actionpack (= 8.1.0.alpha) - activesupport (= 8.1.0.alpha) + railties (= 8.1.0.beta1) + railties (8.1.0.beta1) + actionpack (= 8.1.0.beta1) + activesupport (= 8.1.0.beta1) irb (~> 1.13) rackup (>= 1.0.0) rake (>= 12.2) diff --git a/RAILS_VERSION b/RAILS_VERSION index cd8590d8d7c72..69a59d04b6eff 100644 --- a/RAILS_VERSION +++ b/RAILS_VERSION @@ -1 +1 @@ -8.1.0.alpha +8.1.0.beta1 diff --git a/actioncable/CHANGELOG.md b/actioncable/CHANGELOG.md index 4d055e5564bce..b4419dd4e9828 100644 --- a/actioncable/CHANGELOG.md +++ b/actioncable/CHANGELOG.md @@ -1,3 +1,5 @@ +## Rails 8.1.0.beta1 (September 04, 2025) ## + * Allow passing composite channels to `ActionCable::Channel#stream_for` – e.g. `stream_for [ group, group.owner ]` *hey-leon* diff --git a/actioncable/lib/action_cable/gem_version.rb b/actioncable/lib/action_cable/gem_version.rb index fd6a9c80aabce..56acb3af95071 100644 --- a/actioncable/lib/action_cable/gem_version.rb +++ b/actioncable/lib/action_cable/gem_version.rb @@ -12,7 +12,7 @@ module VERSION MAJOR = 8 MINOR = 1 TINY = 0 - PRE = "alpha" + PRE = "beta1" STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".") end diff --git a/actioncable/package.json b/actioncable/package.json index 0a6c0d20b2830..624e638ee1af8 100644 --- a/actioncable/package.json +++ b/actioncable/package.json @@ -1,6 +1,6 @@ { "name": "@rails/actioncable", - "version": "8.1.0-alpha", + "version": "8.1.0-beta1", "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 33398e8780b7f..4d422ab4dbe0a 100644 --- a/actionmailbox/CHANGELOG.md +++ b/actionmailbox/CHANGELOG.md @@ -1,3 +1,5 @@ +## Rails 8.1.0.beta1 (September 04, 2025) ## + * Add `reply_to_address` extension method on `Mail::Message`. *Mr0grog* diff --git a/actionmailbox/lib/action_mailbox/gem_version.rb b/actionmailbox/lib/action_mailbox/gem_version.rb index d351800d34bbb..ff6a579f741f5 100644 --- a/actionmailbox/lib/action_mailbox/gem_version.rb +++ b/actionmailbox/lib/action_mailbox/gem_version.rb @@ -10,7 +10,7 @@ module VERSION MAJOR = 8 MINOR = 1 TINY = 0 - PRE = "alpha" + PRE = "beta1" STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".") end diff --git a/actionmailer/CHANGELOG.md b/actionmailer/CHANGELOG.md index 8dbc6b8ced48f..5d207d8e0baf1 100644 --- a/actionmailer/CHANGELOG.md +++ b/actionmailer/CHANGELOG.md @@ -1,3 +1,5 @@ +## Rails 8.1.0.beta1 (September 04, 2025) ## + * Add `deliver_all_later` to enqueue multiple emails at once. ```ruby diff --git a/actionmailer/lib/action_mailer/gem_version.rb b/actionmailer/lib/action_mailer/gem_version.rb index 0317b4ebdf600..ac8d0b30339a7 100644 --- a/actionmailer/lib/action_mailer/gem_version.rb +++ b/actionmailer/lib/action_mailer/gem_version.rb @@ -10,7 +10,7 @@ module VERSION MAJOR = 8 MINOR = 1 TINY = 0 - PRE = "alpha" + PRE = "beta1" STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".") end diff --git a/actionpack/CHANGELOG.md b/actionpack/CHANGELOG.md index e4349214875fd..ab01224c1c040 100644 --- a/actionpack/CHANGELOG.md +++ b/actionpack/CHANGELOG.md @@ -1,3 +1,5 @@ +## Rails 8.1.0.beta1 (September 04, 2025) ## + * Remove deprecated support to a route to multiple paths. *Rafael Mendonça França* diff --git a/actionpack/lib/action_pack/gem_version.rb b/actionpack/lib/action_pack/gem_version.rb index 83380b6205c94..85c38f50ba794 100644 --- a/actionpack/lib/action_pack/gem_version.rb +++ b/actionpack/lib/action_pack/gem_version.rb @@ -12,7 +12,7 @@ module VERSION MAJOR = 8 MINOR = 1 TINY = 0 - PRE = "alpha" + PRE = "beta1" STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".") end diff --git a/actiontext/CHANGELOG.md b/actiontext/CHANGELOG.md index 87129a3400ff9..17844a0afb97d 100644 --- a/actiontext/CHANGELOG.md +++ b/actiontext/CHANGELOG.md @@ -1,3 +1,5 @@ +## Rails 8.1.0.beta1 (September 04, 2025) ## + * Forward `fill_in_rich_text_area` options to Capybara ```ruby diff --git a/actiontext/lib/action_text/gem_version.rb b/actiontext/lib/action_text/gem_version.rb index e008c92fbf4e7..8a1c20b84d680 100644 --- a/actiontext/lib/action_text/gem_version.rb +++ b/actiontext/lib/action_text/gem_version.rb @@ -12,7 +12,7 @@ module VERSION MAJOR = 8 MINOR = 1 TINY = 0 - PRE = "alpha" + PRE = "beta1" STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".") end diff --git a/actiontext/package.json b/actiontext/package.json index e2775134a8776..5e63e60ac7052 100644 --- a/actiontext/package.json +++ b/actiontext/package.json @@ -1,6 +1,6 @@ { "name": "@rails/actiontext", - "version": "8.1.0-alpha", + "version": "8.1.0-beta1", "description": "Edit and display rich text in Rails applications", "module": "app/assets/javascripts/actiontext.esm.js", "main": "app/assets/javascripts/actiontext.js", diff --git a/actionview/CHANGELOG.md b/actionview/CHANGELOG.md index b1ea8f0d6f72b..e52c9fb79ed70 100644 --- a/actionview/CHANGELOG.md +++ b/actionview/CHANGELOG.md @@ -1,3 +1,5 @@ +## Rails 8.1.0.beta1 (September 04, 2025) ## + * Allow `current_page?` to match against specific HTTP method(s) with a `method:` option. *Ben Sheldon* diff --git a/actionview/lib/action_view/gem_version.rb b/actionview/lib/action_view/gem_version.rb index cc8e22f6645f1..0e50f57e6b3af 100644 --- a/actionview/lib/action_view/gem_version.rb +++ b/actionview/lib/action_view/gem_version.rb @@ -10,7 +10,7 @@ module VERSION MAJOR = 8 MINOR = 1 TINY = 0 - PRE = "alpha" + PRE = "beta1" STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".") end diff --git a/activejob/CHANGELOG.md b/activejob/CHANGELOG.md index b31b04d3a00d5..7ec15f56d3d5a 100644 --- a/activejob/CHANGELOG.md +++ b/activejob/CHANGELOG.md @@ -1,3 +1,5 @@ +## Rails 8.1.0.beta1 (September 04, 2025) ## + * Deprecate built-in `sidekiq` adapter. If you're using this adapter, upgrade to `sidekiq` 7.3.3 or later to use the `sidekiq` gem's adapter. diff --git a/activejob/lib/active_job/gem_version.rb b/activejob/lib/active_job/gem_version.rb index 96c6a703d6814..63e87e8e2e8f2 100644 --- a/activejob/lib/active_job/gem_version.rb +++ b/activejob/lib/active_job/gem_version.rb @@ -10,7 +10,7 @@ module VERSION MAJOR = 8 MINOR = 1 TINY = 0 - PRE = "alpha" + PRE = "beta1" STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".") end diff --git a/activemodel/CHANGELOG.md b/activemodel/CHANGELOG.md index 312a9282178a1..c078c2a9638b2 100644 --- a/activemodel/CHANGELOG.md +++ b/activemodel/CHANGELOG.md @@ -1,3 +1,5 @@ +## Rails 8.1.0.beta1 (September 04, 2025) ## + * Add `except_on:` option for validation callbacks. *Ben Sheldon* diff --git a/activemodel/lib/active_model/gem_version.rb b/activemodel/lib/active_model/gem_version.rb index cef2609b04573..986d3f802d984 100644 --- a/activemodel/lib/active_model/gem_version.rb +++ b/activemodel/lib/active_model/gem_version.rb @@ -10,7 +10,7 @@ module VERSION MAJOR = 8 MINOR = 1 TINY = 0 - PRE = "alpha" + PRE = "beta1" STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".") end diff --git a/activerecord/CHANGELOG.md b/activerecord/CHANGELOG.md index fa5745722e22d..79c54fd3aea36 100644 --- a/activerecord/CHANGELOG.md +++ b/activerecord/CHANGELOG.md @@ -1,3 +1,5 @@ +## Rails 8.1.0.beta1 (September 04, 2025) ## + * Remove deprecated `:unsigned_float` and `:unsigned_decimal` column methods for MySQL. *Rafael Mendonça França* diff --git a/activerecord/lib/active_record/gem_version.rb b/activerecord/lib/active_record/gem_version.rb index 69400f59e1619..9e1c095e7cf8f 100644 --- a/activerecord/lib/active_record/gem_version.rb +++ b/activerecord/lib/active_record/gem_version.rb @@ -10,7 +10,7 @@ module VERSION MAJOR = 8 MINOR = 1 TINY = 0 - PRE = "alpha" + PRE = "beta1" STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".") end diff --git a/activestorage/CHANGELOG.md b/activestorage/CHANGELOG.md index d386949f5661d..9b00f1d586713 100644 --- a/activestorage/CHANGELOG.md +++ b/activestorage/CHANGELOG.md @@ -1,3 +1,5 @@ +## Rails 8.1.0.beta1 (September 04, 2025) ## + * Remove deprecated `:azure` storage service. *Rafael Mendonça França* diff --git a/activestorage/lib/active_storage/gem_version.rb b/activestorage/lib/active_storage/gem_version.rb index 9abd357c44af6..0773f2840b455 100644 --- a/activestorage/lib/active_storage/gem_version.rb +++ b/activestorage/lib/active_storage/gem_version.rb @@ -10,7 +10,7 @@ module VERSION MAJOR = 8 MINOR = 1 TINY = 0 - PRE = "alpha" + PRE = "beta1" STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".") end diff --git a/activestorage/package.json b/activestorage/package.json index cebe328a95a9a..df9b72df8563a 100644 --- a/activestorage/package.json +++ b/activestorage/package.json @@ -1,6 +1,6 @@ { "name": "@rails/activestorage", - "version": "8.1.0-alpha", + "version": "8.1.0-beta1", "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/activesupport/CHANGELOG.md b/activesupport/CHANGELOG.md index 4f7a41032ca67..46b4400c76a48 100644 --- a/activesupport/CHANGELOG.md +++ b/activesupport/CHANGELOG.md @@ -1,3 +1,5 @@ +## Rails 8.1.0.beta1 (September 04, 2025) ## + * Add `ActiveSupport::Cache::Store#namespace=` and `#namespace`. Can be used as an alternative to `Store#clear` in some situations such as parallel diff --git a/activesupport/lib/active_support/gem_version.rb b/activesupport/lib/active_support/gem_version.rb index e2aa6f7e20ce9..f5e19a8521ac3 100644 --- a/activesupport/lib/active_support/gem_version.rb +++ b/activesupport/lib/active_support/gem_version.rb @@ -10,7 +10,7 @@ module VERSION MAJOR = 8 MINOR = 1 TINY = 0 - PRE = "alpha" + PRE = "beta1" STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".") end diff --git a/guides/CHANGELOG.md b/guides/CHANGELOG.md index 16ef144826d7d..6c3b6382b3ce4 100644 --- a/guides/CHANGELOG.md +++ b/guides/CHANGELOG.md @@ -1,3 +1,5 @@ +## Rails 8.1.0.beta1 (September 04, 2025) ## + * In the Active Job bug report template set the queue adapter to the test adapter so that `assert_enqueued_with` can pass. diff --git a/railties/CHANGELOG.md b/railties/CHANGELOG.md index 0e95484726348..d746b942d863f 100644 --- a/railties/CHANGELOG.md +++ b/railties/CHANGELOG.md @@ -1,3 +1,5 @@ +## Rails 8.1.0.beta1 (September 04, 2025) ## + * Add command `rails credentials:fetch PATH` to get the value of a credential from the credentials file. ```bash diff --git a/railties/lib/rails/gem_version.rb b/railties/lib/rails/gem_version.rb index 3f3a5e3f2624a..d711489d0f79b 100644 --- a/railties/lib/rails/gem_version.rb +++ b/railties/lib/rails/gem_version.rb @@ -10,7 +10,7 @@ module VERSION MAJOR = 8 MINOR = 1 TINY = 0 - PRE = "alpha" + PRE = "beta1" STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".") end diff --git a/yarn.lock b/yarn.lock index 80cdfa44e449c..e7b457ec6fccc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3,27 +3,25 @@ "@babel/code-frame@^7.10.4": - version "7.25.7" - resolved "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.25.7.tgz" - integrity sha512-0xZJFNE5XMpENsgfHYTw8FbX4kv53mFLn2i3XPoq69LyhYSCBJtitaHx9QnsVTrsogI4Z3+HtEfZ2/GFPOtf5g== + version "7.22.5" + resolved "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.5.tgz" + integrity sha512-Xmwn266vad+6DAqEB2A6V/CcZVp62BbwVmcOJc2RPuwih1kw02TjQvWVWlcKGbBPd+8/0V5DEkOcizRGYsspYQ== dependencies: - "@babel/highlight" "^7.25.7" - picocolors "^1.0.0" + "@babel/highlight" "^7.22.5" -"@babel/helper-validator-identifier@^7.25.7": - version "7.25.7" - resolved "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.7.tgz" - integrity sha512-AM6TzwYqGChO45oiuPqwL2t20/HdMC1rTPAesnBCgPCSF1x3oN9MVUwQV2iyz4xqWrctwK5RNC8LV22kaQCNYg== +"@babel/helper-validator-identifier@^7.22.5": + version "7.22.5" + resolved "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.5.tgz" + integrity sha512-aJXu+6lErq8ltp+JhkJUfk1MTGyuA4v7f3pA+BJ5HLfNC6nAQ0Cpi9uOquUj8Hehg0aUiHzWQbOVJGao6ztBAQ== -"@babel/highlight@^7.25.7": - version "7.25.7" - resolved "https://registry.npmjs.org/@babel/highlight/-/highlight-7.25.7.tgz" - integrity sha512-iYyACpW3iW8Fw+ZybQK+drQre+ns/tKpXbNESfrhNnPLIklLbXr7MYJ6gPEd0iETGLOK+SxMjVvKb/ffmk+FEw== +"@babel/highlight@^7.22.5": + version "7.22.5" + resolved "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.5.tgz" + integrity sha512-BSKlD1hgnedS5XRnGOljZawtag7H1yPfQp0tdNJCHoH6AZ+Pcm9VvkrK59/Yy593Ypg0zMxH2BxD1VPYUQ7UIw== dependencies: - "@babel/helper-validator-identifier" "^7.25.7" - chalk "^2.4.2" + "@babel/helper-validator-identifier" "^7.22.5" + chalk "^2.0.0" js-tokens "^4.0.0" - picocolors "^1.0.0" "@colors/colors@1.5.0": version "1.5.0" @@ -81,45 +79,10 @@ resolved "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz" integrity sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA== -"@jridgewell/gen-mapping@^0.3.5": - version "0.3.5" - resolved "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz" - integrity sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg== - dependencies: - "@jridgewell/set-array" "^1.2.1" - "@jridgewell/sourcemap-codec" "^1.4.10" - "@jridgewell/trace-mapping" "^0.3.24" - -"@jridgewell/resolve-uri@^3.1.0": - version "3.1.2" - resolved "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz" - integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw== - -"@jridgewell/set-array@^1.2.1": - version "1.2.1" - resolved "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz" - integrity sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A== - "@jridgewell/source-map@^0.3.3": - version "0.3.6" - resolved "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz" - integrity sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ== - dependencies: - "@jridgewell/gen-mapping" "^0.3.5" - "@jridgewell/trace-mapping" "^0.3.25" - -"@jridgewell/sourcemap-codec@^1.4.10", "@jridgewell/sourcemap-codec@^1.4.14": - version "1.5.0" - resolved "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz" - integrity sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ== - -"@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25": - version "0.3.25" - resolved "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz" - integrity sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ== - dependencies: - "@jridgewell/resolve-uri" "^3.1.0" - "@jridgewell/sourcemap-codec" "^1.4.14" + version "0.3.4" + resolved "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.4.tgz" + integrity sha512-KE/SxsDqNs3rrWwFHcRh15ZLVFrI0YoZtgAdIyIq9k5hUNmiWRXXThPomIxHuL20sLdgzbDFyvkUMna14bvtrw== "@nodelib/fs.scandir@2.1.5": version "2.1.5" @@ -142,6 +105,22 @@ "@nodelib/fs.scandir" "2.1.5" fastq "^1.6.0" +"@rails/actioncable@file:/workspaces/rails/actioncable": + version "8.1.0-beta1" + resolved "file:actioncable" + +"@rails/actiontext@file:/workspaces/rails/actiontext": + version "8.1.0-beta1" + resolved "file:actiontext" + dependencies: + "@rails/activestorage" ">= 8.1.0-alpha" + +"@rails/activestorage@>= 8.1.0-alpha", "@rails/activestorage@file:/workspaces/rails/activestorage": + version "8.1.0-beta1" + resolved "file:activestorage" + dependencies: + spark-md5 "^3.0.1" + "@rollup/plugin-commonjs@^19.0.1": version "19.0.2" resolved "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-19.0.2.tgz" @@ -204,11 +183,9 @@ integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ== "@types/node@*", "@types/node@>=10.0.0": - version "22.7.6" - resolved "https://registry.npmjs.org/@types/node/-/node-22.7.6.tgz" - integrity sha512-/d7Rnj0/ExXDMcioS78/kf1lMzYk4BZV8MZGTBKzTGZ6/406ukkbYlIsZmMPhcR5KlkunDHQLrtAVmSq7r+mSw== - dependencies: - undici-types "~6.19.2" + version "20.3.2" + resolved "https://registry.npmjs.org/@types/node/-/node-20.3.2.tgz" + integrity sha512-vOBLVQeCQfIcF/2Y7eKFTqrMnizK5lRNQ7ykML/5RuwVXVWxYkgwS7xbt4B6fKCUPgbSL5FSsjHQpaGQP/dQmw== "@types/resolve@1.17.1": version "1.17.1" @@ -235,7 +212,7 @@ acorn-jsx@^5.3.2: resolved "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz" integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== -acorn@^8.8.2, acorn@^8.9.0: +"acorn@^6.0.0 || ^7.0.0 || ^8.0.0", acorn@^8.8.2, acorn@^8.9.0: version "8.12.1" resolved "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz" integrity sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg== @@ -245,13 +222,6 @@ adm-zip@~0.4.3: resolved "https://registry.npmjs.org/adm-zip/-/adm-zip-0.4.16.tgz" integrity sha512-TFi4HBKSGfIKsK5YCkKaaFG2m4PEDyViZmEwof3MTIgzimHLto6muaHVpbrljdIvIrFZzEq/p4nafOeLcYegrg== -agent-base@6: - version "6.0.2" - resolved "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz" - integrity sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ== - dependencies: - debug "4" - agent-base@^4.3.0: version "4.3.0" resolved "https://registry.npmjs.org/agent-base/-/agent-base-4.3.0.tgz" @@ -259,6 +229,13 @@ agent-base@^4.3.0: dependencies: es6-promisify "^5.0.0" +agent-base@6: + version "6.0.2" + resolved "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz" + integrity sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ== + dependencies: + debug "4" + ajv@^6.12.3, ajv@^6.12.4: version "6.12.6" resolved "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz" @@ -401,7 +378,7 @@ asn1@~0.2.3: dependencies: safer-buffer "~2.1.0" -assert-plus@1.0.0, assert-plus@^1.0.0: +assert-plus@^1.0.0, assert-plus@1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz" integrity sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw== @@ -445,7 +422,7 @@ base64-js@^1.3.1: resolved "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz" integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== -base64id@2.0.0, base64id@~2.0.0: +base64id@~2.0.0, base64id@2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz" integrity sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog== @@ -532,7 +509,15 @@ bytes@3.1.2: resolved "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz" integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== -call-bind@^1.0.2, call-bind@^1.0.5, call-bind@^1.0.6, call-bind@^1.0.7: +call-bind@^1.0.2: + version "1.0.2" + resolved "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz" + integrity sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA== + dependencies: + function-bind "^1.1.1" + get-intrinsic "^1.0.2" + +call-bind@^1.0.5, call-bind@^1.0.6, call-bind@^1.0.7: version "1.0.7" resolved "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz" integrity sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w== @@ -553,7 +538,7 @@ caseless@~0.12.0: resolved "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz" integrity sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw== -chalk@^2.4.2: +chalk@^2.0.0: version "2.4.2" resolved "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz" integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== @@ -608,16 +593,16 @@ color-convert@^2.0.1: dependencies: color-name "~1.1.4" -color-name@1.1.3: - version "1.1.3" - resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz" - integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw== - color-name@~1.1.4: version "1.1.4" resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== +color-name@1.1.3: + version "1.1.3" + resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz" + integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw== + combined-stream@^1.0.6, combined-stream@~1.0.6: version "1.0.8" resolved "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz" @@ -625,16 +610,16 @@ combined-stream@^1.0.6, combined-stream@~1.0.6: dependencies: delayed-stream "~1.0.0" -commander@7.2.0: - version "7.2.0" - resolved "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz" - integrity sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw== - commander@^2.20.0: version "2.20.3" resolved "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz" integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== +commander@7.2.0: + version "7.2.0" + resolved "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz" + integrity sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw== + commondir@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz" @@ -670,21 +655,21 @@ content-type@~1.0.5: resolved "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz" integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== -cookie@~0.7.2: - version "0.7.2" - resolved "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz" - integrity sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w== - -core-util-is@1.0.2: - version "1.0.2" - resolved "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz" - integrity sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ== +cookie@~0.4.1: + version "0.4.2" + resolved "https://registry.npmjs.org/cookie/-/cookie-0.4.2.tgz" + integrity sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA== core-util-is@~1.0.0: version "1.0.3" resolved "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz" integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ== +core-util-is@1.0.2: + version "1.0.2" + resolved "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz" + integrity sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ== + cors@~2.8.5: version "2.8.5" resolved "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz" @@ -693,6 +678,13 @@ cors@~2.8.5: object-assign "^4" vary "^1" +crc@^3.4.4: + version "3.8.0" + resolved "https://registry.npmjs.org/crc/-/crc-3.8.0.tgz" + integrity sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ== + dependencies: + buffer "^5.1.0" + crc32-stream@^3.0.1: version "3.0.1" resolved "https://registry.npmjs.org/crc32-stream/-/crc32-stream-3.0.1.tgz" @@ -701,13 +693,6 @@ crc32-stream@^3.0.1: crc "^3.4.4" readable-stream "^3.4.0" -crc@^3.4.4: - version "3.8.0" - resolved "https://registry.npmjs.org/crc/-/crc-3.8.0.tgz" - integrity sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ== - dependencies: - buffer "^5.1.0" - cross-spawn@^7.0.2: version "7.0.3" resolved "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz" @@ -761,26 +746,33 @@ date-format@^4.0.14: resolved "https://registry.npmjs.org/date-format/-/date-format-4.0.14.tgz" integrity sha512-39BOQLs9ZjKh0/patS9nrT8wc3ioX3/eA/zgbKNopnF2wCqJEoxywwwElATYvRsXdnOxA/OQeQoFZ3rFjVajhg== -debug@2.6.9: - version "2.6.9" - resolved "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz" - integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== +debug@^3.1.0: + version "3.2.7" + resolved "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz" + integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ== dependencies: - ms "2.0.0" + ms "^2.1.1" + +debug@^3.2.7: + version "3.2.7" + resolved "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz" + integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ== + dependencies: + ms "^2.1.1" -debug@4, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@~4.3.1, debug@~4.3.2, debug@~4.3.4: +debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@~4.3.1, debug@~4.3.2, debug@~4.3.4, debug@4: version "4.3.4" resolved "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz" integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== dependencies: ms "2.1.2" -debug@^3.1.0, debug@^3.2.7: - version "3.2.7" - resolved "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz" - integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ== +debug@2.6.9: + version "2.6.9" + resolved "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz" + integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== dependencies: - ms "^2.1.1" + ms "2.0.0" deep-is@^0.1.3: version "0.1.4" @@ -897,17 +889,17 @@ engine.io-parser@~5.2.1: resolved "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz" integrity sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q== -engine.io@~6.6.0: - version "6.6.2" - resolved "https://registry.npmjs.org/engine.io/-/engine.io-6.6.2.tgz" - integrity sha512-gmNvsYi9C8iErnZdVcJnvCpSKbWTt1E8+JZo8b+daLninywUWi5NQ5STSHZ9rFjFO7imNcvb8Pc5pe/wMR5xEw== +engine.io@~6.5.2: + version "6.5.5" + resolved "https://registry.npmjs.org/engine.io/-/engine.io-6.5.5.tgz" + integrity sha512-C5Pn8Wk+1vKBoHghJODM63yk8MvrO9EWZUfkAt5HAqIgPE4/8FF0PEGHXtEd40l223+cE5ABWuPzm38PHFXfMA== dependencies: "@types/cookie" "^0.4.1" "@types/cors" "^2.8.12" "@types/node" ">=10.0.0" accepts "~1.3.4" base64id "2.0.0" - cookie "~0.7.2" + cookie "~0.4.1" cors "~2.8.5" debug "~4.3.1" engine.io-parser "~5.2.1" @@ -1062,7 +1054,7 @@ eslint-module-utils@^2.8.0: dependencies: debug "^3.2.7" -eslint-plugin-import@^2.29.0: +eslint-plugin-import@^2.23.4, eslint-plugin-import@^2.29.0: version "2.29.0" resolved "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.29.0.tgz" integrity sha512-QPOO5NO6Odv5lpoTkddtutccQjysJuFxoPS7fAHO+9m9udNHvTCPSAMW9zGAYj8lAIdr40I8yPCdUYrncXtrwg== @@ -1098,7 +1090,7 @@ eslint-visitor-keys@^3.3.0, eslint-visitor-keys@^3.4.1, eslint-visitor-keys@^3.4 resolved "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz" integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag== -eslint@^8.40.0: +"eslint@^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8", eslint@^4.19.1, "eslint@^6.0.0 || ^7.0.0 || >=8.0.0", eslint@^8.40.0: version "8.57.1" resolved "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz" integrity sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA== @@ -1195,7 +1187,7 @@ extend@^3.0.0, extend@~3.0.2: resolved "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz" integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== -extsprintf@1.3.0, extsprintf@^1.2.0: +extsprintf@^1.2.0, extsprintf@1.3.0: version "1.3.0" resolved "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz" integrity sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g== @@ -1322,12 +1314,12 @@ fs.realpath@^1.0.0: resolved "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz" integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== -fsevents@~2.3.2: - version "2.3.3" - resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" - integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== +function-bind@^1.1.1: + version "1.1.1" + resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz" + integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A== -function-bind@^1.1.1, function-bind@^1.1.2: +function-bind@^1.1.2: version "1.1.2" resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz" integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== @@ -1352,7 +1344,37 @@ get-caller-file@^2.0.5: resolved "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz" integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== -get-intrinsic@^1.1.3, get-intrinsic@^1.2.1, get-intrinsic@^1.2.3, get-intrinsic@^1.2.4: +get-intrinsic@^1.0.2: + version "1.2.1" + resolved "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.1.tgz" + integrity sha512-2DcsyfABl+gVHEfCOaTrWgyt+tb6MSEGmKq+kI5HwLbIYgjgmMcV8KQ41uaKz1xxUcn9tJtgFbQUEVcEbd0FYw== + dependencies: + function-bind "^1.1.1" + has "^1.0.3" + has-proto "^1.0.1" + has-symbols "^1.0.3" + +get-intrinsic@^1.1.1: + version "1.2.1" + resolved "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.1.tgz" + integrity sha512-2DcsyfABl+gVHEfCOaTrWgyt+tb6MSEGmKq+kI5HwLbIYgjgmMcV8KQ41uaKz1xxUcn9tJtgFbQUEVcEbd0FYw== + dependencies: + function-bind "^1.1.1" + has "^1.0.3" + has-proto "^1.0.1" + has-symbols "^1.0.3" + +get-intrinsic@^1.1.3: + version "1.2.1" + resolved "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.1.tgz" + integrity sha512-2DcsyfABl+gVHEfCOaTrWgyt+tb6MSEGmKq+kI5HwLbIYgjgmMcV8KQ41uaKz1xxUcn9tJtgFbQUEVcEbd0FYw== + dependencies: + function-bind "^1.1.1" + has "^1.0.3" + has-proto "^1.0.1" + has-symbols "^1.0.3" + +get-intrinsic@^1.2.1, get-intrinsic@^1.2.3, get-intrinsic@^1.2.4: version "1.2.4" resolved "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz" integrity sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ== @@ -1474,14 +1496,26 @@ has-flag@^4.0.0: resolved "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz" integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== -has-property-descriptors@^1.0.0, has-property-descriptors@^1.0.2: +has-property-descriptors@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.0.tgz" + integrity sha512-62DVLZGoiEBDHQyqG4w9xCuZ7eJEwNmJRWw2VY84Oedb7WFcA27fiEVe8oUQx9hAUJ4ekurquucTGwsyO1XGdQ== + dependencies: + get-intrinsic "^1.1.1" + +has-property-descriptors@^1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz" integrity sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg== dependencies: es-define-property "^1.0.0" -has-proto@^1.0.1, has-proto@^1.0.3: +has-proto@^1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/has-proto/-/has-proto-1.0.1.tgz" + integrity sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg== + +has-proto@^1.0.3: version "1.0.3" resolved "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz" integrity sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q== @@ -1602,7 +1636,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@2, inherits@2.0.4, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.3: +inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.3, inherits@2, inherits@2.0.4: version "2.0.4" resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -1651,7 +1685,14 @@ is-callable@^1.1.3, is-callable@^1.1.4, is-callable@^1.2.7: resolved "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz" integrity sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA== -is-core-module@^2.11.0, is-core-module@^2.13.0, is-core-module@^2.13.1: +is-core-module@^2.11.0: + version "2.12.1" + resolved "https://registry.npmjs.org/is-core-module/-/is-core-module-2.12.1.tgz" + integrity sha512-Q4ZuBAe2FUsKtyQJoQHlvP8OvBERxO3jEmy1I7hcRXcJBGGHFh/aJBswbXuS9sgrDH2QUO8ilkwNPHvHMd8clg== + dependencies: + has "^1.0.3" + +is-core-module@^2.13.0, is-core-module@^2.13.1: version "2.13.1" resolved "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz" integrity sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw== @@ -1731,7 +1772,14 @@ is-regex@^1.1.4: call-bind "^1.0.2" has-tostringtag "^1.0.0" -is-shared-array-buffer@^1.0.2, is-shared-array-buffer@^1.0.3: +is-shared-array-buffer@^1.0.2: + version "1.0.2" + resolved "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.2.tgz" + integrity sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA== + dependencies: + call-bind "^1.0.2" + +is-shared-array-buffer@^1.0.3: version "1.0.3" resolved "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.3.tgz" integrity sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg== @@ -1889,7 +1937,7 @@ karma-sauce-launcher@^1.2.0: saucelabs "^1.4.0" wd "^1.4.0" -karma@^6.4.2: +karma@^3.1.1, karma@^6.4.2: version "6.4.4" resolved "https://registry.npmjs.org/karma/-/karma-6.4.4.tgz" integrity sha512-LrtUxbdvt1gOpo3gxG+VAJlJAEMhbWlM4YrFQgql98FwF7+K8K12LYO4hnDdUkNjeztYrOXEMqgTajSWgmtI/w== @@ -2047,6 +2095,11 @@ mock-socket@^2.0.0: dependencies: urijs "~1.17.0" +ms@^2.1.1: + version "2.1.3" + resolved "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + ms@2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz" @@ -2057,11 +2110,6 @@ ms@2.1.2: resolved "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== -ms@^2.1.1: - version "2.1.3" - resolved "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz" - integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== - natural-compare@^1.4.0: version "1.4.0" resolved "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz" @@ -2145,13 +2193,6 @@ object.values@^1.1.7: define-properties "^1.2.0" es-abstract "^1.22.1" -on-finished@2.4.1: - version "2.4.1" - resolved "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz" - integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg== - dependencies: - ee-first "1.1.1" - on-finished@~2.3.0: version "2.3.0" resolved "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz" @@ -2159,6 +2200,13 @@ on-finished@~2.3.0: dependencies: ee-first "1.1.1" +on-finished@2.4.1: + version "2.4.1" + resolved "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz" + integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg== + dependencies: + ee-first "1.1.1" + once@^1.3.0, once@^1.4.0: version "1.4.0" resolved "https://registry.npmjs.org/once/-/once-1.4.0.tgz" @@ -2229,11 +2277,6 @@ performance-now@^2.1.0: resolved "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz" integrity sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow== -picocolors@^1.0.0: - version "1.1.1" - resolved "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz" - integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== - picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.2: version "2.3.1" resolved "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz" @@ -2279,6 +2322,11 @@ qjobs@^1.2.0: resolved "https://registry.npmjs.org/qjobs/-/qjobs-1.2.0.tgz" integrity sha512-8YOJEHtxpySA3fFDyCRxA+UUV+fA+rTWnuWvylOK/NCjhY+b4ocCtmu8TtsWb+mYeU+GCHf/S66KZF/AsteKHg== +qs@~6.5.2: + version "6.5.3" + resolved "https://registry.npmjs.org/qs/-/qs-6.5.3.tgz" + integrity sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA== + qs@6.11.0: version "6.11.0" resolved "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz" @@ -2286,17 +2334,12 @@ qs@6.11.0: dependencies: side-channel "^1.0.4" -qs@~6.5.2: - version "6.5.3" - resolved "https://registry.npmjs.org/qs/-/qs-6.5.3.tgz" - integrity sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA== - queue-microtask@^1.2.2: version "1.2.3" resolved "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz" integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== -qunit@^2.8.0: +qunit@^2.0.0, qunit@^2.8.0: version "2.19.4" resolved "https://registry.npmjs.org/qunit/-/qunit-2.19.4.tgz" integrity sha512-aqUzzUeCqlleWYKlpgfdHHw9C6KxkB9H3wNfiBg5yHqQMzy0xw/pbCRHYFkjl8MsP/t8qkTQE+JTYL71azgiew== @@ -2327,7 +2370,33 @@ raw-body@2.5.2: iconv-lite "0.4.24" unpipe "1.0.0" -readable-stream@^2.0.0, readable-stream@^2.0.5, readable-stream@^2.3.6: +readable-stream@^2.0.0: + version "2.3.8" + resolved "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz" + integrity sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA== + dependencies: + core-util-is "~1.0.0" + inherits "~2.0.3" + isarray "~1.0.0" + process-nextick-args "~2.0.0" + safe-buffer "~5.1.1" + string_decoder "~1.1.1" + util-deprecate "~1.0.1" + +readable-stream@^2.0.5: + version "2.3.8" + resolved "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz" + integrity sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA== + dependencies: + core-util-is "~1.0.0" + inherits "~2.0.3" + isarray "~1.0.0" + process-nextick-args "~2.0.0" + safe-buffer "~5.1.1" + string_decoder "~1.1.1" + util-deprecate "~1.0.1" + +readable-stream@^2.3.6: version "2.3.8" resolved "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz" integrity sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA== @@ -2431,9 +2500,9 @@ reusify@^1.0.4: integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw== rfdc@^1.3.0: - version "1.4.1" - resolved "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz" - integrity sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA== + version "1.3.1" + resolved "https://registry.npmjs.org/rfdc/-/rfdc-1.3.1.tgz" + integrity sha512-r5a3l5HzYlIC68TpmYKlxWjmOP6wiPJ1vWv2HeLhNsRZMrCkxeqxiHlQ21oXmQ4F3SiryXBHhAD7JZqvOJjFmg== rimraf@^2.5.4: version "2.7.1" @@ -2459,7 +2528,7 @@ rollup-plugin-terser@^7.0.2: serialize-javascript "^4.0.0" terser "^5.0.0" -rollup@^2.35.1: +rollup@^1.20.0||^2.0.0, rollup@^2.0.0, rollup@^2.35.1, rollup@^2.38.3, rollup@^2.53.3: version "2.79.1" resolved "https://registry.npmjs.org/rollup/-/rollup-2.79.1.tgz" integrity sha512-uKxbd0IhMZOhjAiD5oAFp7BqvkA4Dv47qpOCtaNvng4HBwdbWtdOh8f5nZNuk2rp51PMGk3bzfWu5oayNEuYnw== @@ -2502,7 +2571,7 @@ safe-regex-test@^1.0.3: es-errors "^1.3.0" is-regex "^1.1.4" -"safer-buffer@>= 2.1.2 < 3", safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0: +safer-buffer@^2.0.2, safer-buffer@^2.1.0, "safer-buffer@>= 2.1.2 < 3", safer-buffer@~2.1.0: version "2.1.2" resolved "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== @@ -2603,15 +2672,15 @@ socket.io-parser@~4.2.4: debug "~4.3.1" socket.io@^4.7.2: - version "4.8.0" - resolved "https://registry.npmjs.org/socket.io/-/socket.io-4.8.0.tgz" - integrity sha512-8U6BEgGjQOfGz3HHTYaC/L1GaxDCJ/KM0XTkJly0EhZ5U/du9uNEZy4ZgYzEzIqlx2CMm25CrCqr1ck899eLNA== + version "4.7.5" + resolved "https://registry.npmjs.org/socket.io/-/socket.io-4.7.5.tgz" + integrity sha512-DmeAkF6cwM9jSfmp6Dr/5/mfMwb5Z5qRrSXLpo3Fq5SqyU8CMF15jIN4ZhfSwu35ksM1qmHZDQ/DK5XTccSTvA== dependencies: accepts "~1.3.4" base64id "~2.0.0" cors "~2.8.5" debug "~4.3.2" - engine.io "~6.6.0" + engine.io "~6.5.2" socket.io-adapter "~2.5.2" socket.io-parser "~4.2.4" @@ -2653,16 +2722,16 @@ sshpk@^1.7.0: safer-buffer "^2.0.2" tweetnacl "~0.14.0" -statuses@2.0.1: - version "2.0.1" - resolved "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz" - integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ== - statuses@~1.5.0: version "1.5.0" resolved "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz" integrity sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA== +statuses@2.0.1: + version "2.0.1" + resolved "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz" + integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ== + streamroller@^3.1.5: version "3.1.5" resolved "https://registry.npmjs.org/streamroller/-/streamroller-3.1.5.tgz" @@ -2672,6 +2741,20 @@ streamroller@^3.1.5: debug "^4.3.4" fs-extra "^8.1.0" +string_decoder@^1.1.1: + version "1.3.0" + resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz" + integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== + dependencies: + safe-buffer "~5.2.0" + +string_decoder@~1.1.1: + version "1.1.1" + resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz" + integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg== + dependencies: + safe-buffer "~5.1.0" + string-width@^4.1.0, string-width@^4.2.0: version "4.2.3" resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" @@ -2709,20 +2792,6 @@ string.prototype.trimstart@^1.0.8: define-properties "^1.2.1" es-object-atoms "^1.0.0" -string_decoder@^1.1.1: - version "1.3.0" - resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz" - integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== - dependencies: - safe-buffer "~5.2.0" - -string_decoder@~1.1.1: - version "1.1.1" - resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz" - integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg== - dependencies: - safe-buffer "~5.1.0" - strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" @@ -2771,9 +2840,9 @@ tar-stream@^2.1.0: readable-stream "^3.1.1" terser@^5.0.0: - version "5.36.0" - resolved "https://registry.npmjs.org/terser/-/terser-5.36.0.tgz" - integrity sha512-IYV9eNMuFAV4THUspIRXkLakHnV6XO7FEdtKjf/mDyrnqUg9LnlOn6/RwRvM9SZjR4GUq8Nk8zj67FzVARr74w== + version "5.18.2" + resolved "https://registry.npmjs.org/terser/-/terser-5.18.2.tgz" + integrity sha512-Ah19JS86ypbJzTzvUCX7KOsEIhDaRONungA4aYBjEP3JZRf4ocuDzTg4QWZnPn9DEMiMYGJPiSOy7aykoCc70w== dependencies: "@jridgewell/source-map" "^0.3.3" acorn "^8.8.2" @@ -2819,9 +2888,9 @@ tough-cookie@~2.4.3: punycode "^1.4.1" trix@^2.0.0: - version "2.1.7" - resolved "https://registry.npmjs.org/trix/-/trix-2.1.7.tgz" - integrity sha512-RyFmjLJfxP2nuAKqgVqJ40wk4qoYfDQtyi71q6ozkP+X4EOILe+j5ll5g/suvTyMx7BacGszNWzjnx9Vbj17sw== + version "2.0.5" + resolved "https://registry.npmjs.org/trix/-/trix-2.0.5.tgz" + integrity sha512-OiCbDf17F7JahEwhyL1MvK9DxAAT1vkaW5sn+zpwfemZAcc4RfQB4ku18/1mKP58LRwBhjcy+6TBho7ciXz52Q== tsconfig-paths@^3.14.2: version "3.14.2" @@ -2924,17 +2993,12 @@ unbox-primitive@^1.0.2: has-symbols "^1.0.3" which-boxed-primitive "^1.0.2" -undici-types@~6.19.2: - version "6.19.8" - resolved "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz" - integrity sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw== - universalify@^0.1.0: version "0.1.2" resolved "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz" integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg== -unpipe@1.0.0, unpipe@~1.0.0: +unpipe@~1.0.0, unpipe@1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz" integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ== From 01c491f6f27fbbac33400792305b0f1020c466be Mon Sep 17 00:00:00 2001 From: Yasuo Honda Date: Mon, 28 Jul 2025 14:27:32 +0900 Subject: [PATCH 0575/1075] Skip calling PG::Connection#cancel when using libpq >= 18 with pg < 1.6.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PostgreSQL 18 introduces protocol version 3.2, which changes the length of the cancel request key. This makes PG::Connection#cancel fail if the pg gem is older than 1.6.0. PG::Connection#cancel is invoked from cancel_any_running_query, which is only called from exec_rollback_db_transaction and exec_restart_db_transaction. Skipping the cancel call may slow down rollback, but rollback still completes. Before this change, pull request #55368 checked the PostgreSQL server version inside check_version. That was incorrect—the incompatibility depends on the pg gem version and the client library version (libpq), as discussed in #55368. References: - https://github.com/ged/ruby-pg/pull/614 - https://github.com/rails/rails/pull/55368 - https://github.com/rails/rails/pull/55413 Co-authored-by: Lars Kanis --- activerecord/CHANGELOG.md | 10 ++++++---- .../postgresql/database_statements.rb | 9 ++++++++- .../connection_adapters/postgresql_adapter.rb | 3 --- .../test/cases/adapters/postgresql/transaction_test.rb | 4 ++-- 4 files changed, 16 insertions(+), 10 deletions(-) diff --git a/activerecord/CHANGELOG.md b/activerecord/CHANGELOG.md index 79c54fd3aea36..2a99e1ab01300 100644 --- a/activerecord/CHANGELOG.md +++ b/activerecord/CHANGELOG.md @@ -1,3 +1,9 @@ +* Skip calling `PG::Connection#cancel` in `cancel_any_running_query` + when using libpq >= 18 with pg < 1.6.0, due to incompatibility. + Rollback still runs, but may take longer. + + *Yasuo Honda*, *Lars Kanis* + ## Rails 8.1.0.beta1 (September 04, 2025) ## * Remove deprecated `:unsigned_float` and `:unsigned_decimal` column methods for MySQL. @@ -71,10 +77,6 @@ *Kir Shatrov* -* Emit a warning for pg gem < 1.6.0 when using PostgreSQL 18+ - - *Yasuo Honda* - * Fix `#merge` with `#or` or `#and` and a mixture of attributes and SQL strings resulting in an incorrect query. ```ruby 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 46acf105be402..96a9304288071 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/database_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/database_statements.rb @@ -127,7 +127,14 @@ def set_constraints(deferred, *constraints) def cancel_any_running_query return if @raw_connection.nil? || IDLE_TRANSACTION_STATUSES.include?(@raw_connection.transaction_status) - @raw_connection.cancel + # Skip @raw_connection.cancel (PG::Connection#cancel) when using libpq >= 18 with pg < 1.6.0, + # because the pg gem cannot obtain the backend_key in that case. + # This method is only called from exec_rollback_db_transaction and exec_restart_db_transaction. + # Even without cancel, rollback will still run. However, since any running + # query must finish first, the rollback may take longer. + if !(PG.library_version >= 18_00_00 && Gem::Version.new(PG::VERSION) < Gem::Version.new("1.6.0")) + @raw_connection.cancel + end @raw_connection.block rescue PG::Error end diff --git a/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb b/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb index ff78e840360ba..18de3894e26de 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb @@ -666,9 +666,6 @@ def check_version # :nodoc: if database_version < 9_03_00 # < 9.3 raise "Your version of PostgreSQL (#{database_version}) is too old. Active Record supports PostgreSQL >= 9.3." end - if database_version >= 18_00_00 && Gem::Version.new(PG::VERSION) < Gem::Version.new("1.6.0") - warn "pg gem version #{PG::VERSION} is known to be incompatible with PostgreSQL 18+. Please upgrade to pg 1.6.0 or later." - end end class << self diff --git a/activerecord/test/cases/adapters/postgresql/transaction_test.rb b/activerecord/test/cases/adapters/postgresql/transaction_test.rb index 28e5460651bfb..3f61199012bc8 100644 --- a/activerecord/test/cases/adapters/postgresql/transaction_test.rb +++ b/activerecord/test/cases/adapters/postgresql/transaction_test.rb @@ -181,8 +181,8 @@ class Sample < ActiveRecord::Base end test "raises Interrupt when canceling statement via interrupt" do - if ActiveRecord::Base.lease_connection.database_version >= 18_00_00 && Gem::Version.new(PG::VERSION) < Gem::Version.new("1.6.0") - skip "pg gem version #{PG::VERSION} is known to be incompatible with PostgreSQL 18+. " + if PG.library_version >= 18_00_00 && Gem::Version.new(PG::VERSION) < Gem::Version.new("1.6.0") + skip "PG::Connection#cancel should not run when libpq of PostgreSQL #{PG.library_version / 10000} with pg gem version #{PG::VERSION}" end start_time = Time.now thread = Thread.new do From 1e344b3bc49f4bc2854a9ae730a70c542c8d5382 Mon Sep 17 00:00:00 2001 From: Yasuo Honda Date: Thu, 24 Oct 2024 20:43:28 +0900 Subject: [PATCH 0576/1075] PostgreSQL 18devel does not support unlogged option on partitioned tables This pull request disables UNLOGGED option for partitioned tables used in Active Record unit tests. https://github.com/rails/rails/pull/47499 configures the following option to use UNLOGGED tables to make unit test faster. ``` ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.create_unlogged_tables ``` PostgreSQL 18 is being developed and it removes support unlogged option on partitioned table. - Remove support for unlogged on partitioned tables https://github.com/postgres/postgres/commit/e2bab2d7920 According to this change Active Record unit tests gets the "partitioned tables cannot be unlogged" error. ```ruby $ cd activerecord $ ARCONN=postgresql bin/test test/cases/adapters/postgresql/partitions_test.rb -n test_partitions_table_exists Using postgresql Run options: -n test_partitions_table_exists --seed 35335 E Error: PostgreSQLPartitionsTest#test_partitions_table_exists: ActiveRecord::StatementInvalid: PG::FeatureNotSupported: ERROR: partitioned tables cannot be unlogged lib/active_record/connection_adapters/postgresql/database_statements.rb:160:in `exec' lib/active_record/connection_adapters/postgresql/database_statements.rb:160:in `perform_query' lib/active_record/connection_adapters/abstract/database_statements.rb:556:in `block (2 levels) in raw_execute' lib/active_record/connection_adapters/abstract_adapter.rb:1011:in `block in with_raw_connection' /home/yahonda/src/github.com/rails/rails/activesupport/lib/active_support/concurrency/load_interlock_aware_monitor.rb:23:in `handle_interrupt' /home/yahonda/src/github.com/rails/rails/activesupport/lib/active_support/concurrency/load_interlock_aware_monitor.rb:23:in `block in synchronize' /home/yahonda/src/github.com/rails/rails/activesupport/lib/active_support/concurrency/load_interlock_aware_monitor.rb:19:in `handle_interrupt' /home/yahonda/src/github.com/rails/rails/activesupport/lib/active_support/concurrency/load_interlock_aware_monitor.rb:19:in `synchronize' lib/active_record/connection_adapters/abstract_adapter.rb:983:in `with_raw_connection' lib/active_record/connection_adapters/abstract/database_statements.rb:555:in `block in raw_execute' /home/yahonda/src/github.com/rails/rails/activesupport/lib/active_support/notifications/instrumenter.rb:58:in `instrument' lib/active_record/connection_adapters/abstract_adapter.rb:1129:in `log' lib/active_record/connection_adapters/abstract/database_statements.rb:554:in `raw_execute' lib/active_record/connection_adapters/abstract/database_statements.rb:591:in `internal_execute' lib/active_record/connection_adapters/abstract/database_statements.rb:137:in `execute' lib/active_record/connection_adapters/abstract/query_cache.rb:27:in `execute' lib/active_record/connection_adapters/postgresql/database_statements.rb:40:in `execute' lib/active_record/connection_adapters/abstract/schema_statements.rb:309:in `create_table' test/cases/adapters/postgresql/partitions_test.rb:16:in `test_partitions_table_exists' ``` - SQL executed without this fix ``` CREATE UNLOGGED TABLE "partitioned_events" ("issued_at" timestamp) partition by range (issued_at) ``` - SQL executed with this fix ``` CREATE TABLE "partitioned_events" ("issued_at" timestamp) partition by range (issued_at) ``` --- .../adapters/postgresql/partitions_test.rb | 3 +++ .../cases/adapters/postgresql/schema_test.rb | 3 +++ .../test/schema/postgresql_specific_schema.rb | 27 ++++++++++++------- 3 files changed, 23 insertions(+), 10 deletions(-) diff --git a/activerecord/test/cases/adapters/postgresql/partitions_test.rb b/activerecord/test/cases/adapters/postgresql/partitions_test.rb index 960bf298d8d45..53e943b8e5dbc 100644 --- a/activerecord/test/cases/adapters/postgresql/partitions_test.rb +++ b/activerecord/test/cases/adapters/postgresql/partitions_test.rb @@ -4,11 +4,14 @@ class PostgreSQLPartitionsTest < ActiveRecord::PostgreSQLTestCase def setup + @previous_unlogged_tables = ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.create_unlogged_tables @connection = ActiveRecord::Base.lease_connection + ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.create_unlogged_tables = false end def teardown @connection.drop_table "partitioned_events", if_exists: true + ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.create_unlogged_tables = @previous_unlogged_tables end def test_partitions_table_exists diff --git a/activerecord/test/cases/adapters/postgresql/schema_test.rb b/activerecord/test/cases/adapters/postgresql/schema_test.rb index 1e186fc4fdcc3..5badbad3a3677 100644 --- a/activerecord/test/cases/adapters/postgresql/schema_test.rb +++ b/activerecord/test/cases/adapters/postgresql/schema_test.rb @@ -910,13 +910,16 @@ class SchemaCreateTableOptionsTest < ActiveRecord::PostgreSQLTestCase include SchemaDumpingHelper setup do + @previous_unlogged_tables = ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.create_unlogged_tables @connection = ActiveRecord::Base.connection + ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.create_unlogged_tables = false end teardown do @connection.drop_table "trains", if_exists: true @connection.drop_table "transportation_modes", if_exists: true @connection.drop_table "vehicles", if_exists: true + ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.create_unlogged_tables = @previous_unlogged_tables end def test_list_partition_options_is_dumped diff --git a/activerecord/test/schema/postgresql_specific_schema.rb b/activerecord/test/schema/postgresql_specific_schema.rb index 53cc9cbd8aa8d..9fdda11b000e9 100644 --- a/activerecord/test/schema/postgresql_specific_schema.rb +++ b/activerecord/test/schema/postgresql_specific_schema.rb @@ -185,17 +185,24 @@ end if supports_partitioned_indexes? - create_table(:measurements, id: false, force: true, options: "PARTITION BY LIST (city_id)") do |t| - t.string :city_id, null: false - t.date :logdate, null: false - t.integer :peaktemp - t.integer :unitsales - t.index [:logdate, :city_id], unique: true + begin + previous_unlogged_tables = ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.create_unlogged_tables + ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.create_unlogged_tables = false + + create_table(:measurements, id: false, force: true, options: "PARTITION BY LIST (city_id)") do |t| + t.string :city_id, null: false + t.date :logdate, null: false + t.integer :peaktemp + t.integer :unitsales + t.index [:logdate, :city_id], unique: true + end + create_table(:measurements_toronto, id: false, force: true, + options: "PARTITION OF measurements FOR VALUES IN (1)") + create_table(:measurements_concepcion, id: false, force: true, + options: "PARTITION OF measurements FOR VALUES IN (2)") + ensure + ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.create_unlogged_tables = previous_unlogged_tables end - create_table(:measurements_toronto, id: false, force: true, - options: "PARTITION OF measurements FOR VALUES IN (1)") - create_table(:measurements_concepcion, id: false, force: true, - options: "PARTITION OF measurements FOR VALUES IN (2)") end add_index(:companies, [:firm_id, :type], name: "company_include_index", include: [:name, :account_id]) From fcc5eba9f7fda3882868e6a5becc09a66f4e7828 Mon Sep 17 00:00:00 2001 From: Harsh Deep Date: Thu, 4 Sep 2025 13:59:12 -0400 Subject: [PATCH 0577/1075] Add link_to "Cancel" in the first new product snippet --- guides/source/getting_started.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/guides/source/getting_started.md b/guides/source/getting_started.md index 7e97d564b4b41..32d8515aeeae2 100644 --- a/guides/source/getting_started.md +++ b/guides/source/getting_started.md @@ -1178,6 +1178,8 @@ Let's create `app/views/products/new.html.erb` to render the form for this new <%= form.submit %>

<% end %> + +<%= link_to "Cancel", products_path %> ``` In this view, we are using the Rails `form_with` helper to generate an HTML form From 53928ebf2f7528ff0f6685bf26ea69bce1728689 Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Fri, 5 Sep 2025 09:10:25 +0200 Subject: [PATCH 0578/1075] Revert "Optimize Active Job ObjectSerializer#serialize" This reverts commit 1f8a0c06c163696618032d1001a0bdf9e2ac1ed1. Some users are depending on the exact byte for byte stability of the job arguments payload. The common use case seem to be job unicity. Changes to the format will come eventually, but first I need to take the time to hear about use cases and find solutions for seemless upgrades. --- activejob/lib/active_job/serializers/object_serializer.rb | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/activejob/lib/active_job/serializers/object_serializer.rb b/activejob/lib/active_job/serializers/object_serializer.rb index 5ca798383e596..c5e4e3120b05d 100644 --- a/activejob/lib/active_job/serializers/object_serializer.rb +++ b/activejob/lib/active_job/serializers/object_serializer.rb @@ -35,8 +35,7 @@ def serialize?(argument) # Serializes an argument to a JSON primitive type. def serialize(hash) - hash[Arguments::OBJECT_SERIALIZER_KEY] = self.class.name - hash + { Arguments::OBJECT_SERIALIZER_KEY => self.class.name }.merge!(hash) end # Deserializes an argument from a JSON primitive type. From 106cde0be3a71bd3ff59c6bbe72749aeaa9baca3 Mon Sep 17 00:00:00 2001 From: John Hawthorn Date: Fri, 5 Sep 2025 01:33:10 -0700 Subject: [PATCH 0579/1075] Use singleton for callbacks default terminator --- activesupport/lib/active_support/callbacks.rb | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/activesupport/lib/active_support/callbacks.rb b/activesupport/lib/active_support/callbacks.rb index ea3c3ccba2273..e4309a57c7760 100644 --- a/activesupport/lib/active_support/callbacks.rb +++ b/activesupport/lib/active_support/callbacks.rb @@ -573,7 +573,7 @@ def initialize(name, config) @name = name @config = { scope: [:kind], - terminator: default_terminator + terminator: DEFAULT_TERMINATOR }.merge!(config) @chain = [] @all_callbacks = nil @@ -661,8 +661,8 @@ def remove_duplicates(callback) @chain.delete_if { |c| callback.duplicates?(c) } end - def default_terminator - Proc.new do |target, result_lambda| + class DefaultTerminator # :nodoc: + def call(target, result_lambda) terminate = true catch(:abort) do result_lambda.call @@ -671,6 +671,7 @@ def default_terminator terminate end end + DEFAULT_TERMINATOR = DefaultTerminator.new.freeze end module ClassMethods From c764897666e67dd629b19e46bef39ee90323cb2d Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Sat, 6 Sep 2025 08:57:27 +0100 Subject: [PATCH 0580/1075] Autoload ActiveJob::Continuable Fixes https://github.com/rails/rails/issues/55620 --- activejob/lib/active_job.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/activejob/lib/active_job.rb b/activejob/lib/active_job.rb index d54825b29b648..e8436a76f53d9 100644 --- a/activejob/lib/active_job.rb +++ b/activejob/lib/active_job.rb @@ -43,6 +43,7 @@ module ActiveJob autoload :EnqueueAfterTransactionCommit eager_autoload do + autoload :Continuable autoload :Continuation autoload :Serializers autoload :ConfiguredJob From 5d79b321785365973fb347456aaa1a37e735c98e Mon Sep 17 00:00:00 2001 From: Hartley McGuire Date: Mon, 17 Feb 2025 17:01:24 -0500 Subject: [PATCH 0581/1075] Improve route visualizer regexp labels The visualizer previously had two problems: - `\A` and `\Z` displayed as `A` and `Z` because `dot` evaluates escaped characters in labels. - `Regexp#to_s` wraps the regular expression in a capture group, `(?-mix:`, which adds a lot of noise (especially since all Regexps in the state machine are wrapped with the "anchoring" Regexp that doesn't enable these options). This commit fixes both of these problems by using `Regexp#source` for the labels as well as adding extra `\` so that `\A` and `\Z` will display properly. Notes: - `transitions` is only used by the `visualizer` (development only), so the `DEFAULT_EXP_ANCHORED` constant is inlined - The block form of `gsub` is used because the non-block form requires the replacement to be "//////", due to the replacement string also being interpolated. The `gsub` docs also mention this approach > You may want to use the block form to avoid excessive backslashes. --- .../lib/action_dispatch/journey/gtg/transition_table.rb | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/actionpack/lib/action_dispatch/journey/gtg/transition_table.rb b/actionpack/lib/action_dispatch/journey/gtg/transition_table.rb index ca3d05599e1e4..a8667f84589fb 100644 --- a/actionpack/lib/action_dispatch/journey/gtg/transition_table.rb +++ b/actionpack/lib/action_dispatch/journey/gtg/transition_table.rb @@ -13,7 +13,6 @@ class TransitionTable # :nodoc: attr_reader :memos DEFAULT_EXP = /[^.\/?]+/ - DEFAULT_EXP_ANCHORED = /\A#{DEFAULT_EXP}\Z/ def initialize @stdparam_states = {} @@ -193,12 +192,15 @@ def states end def transitions + # double escaped because dot evaluates escapes + default_exp_anchored = "\\\\A#{DEFAULT_EXP.source}\\\\Z" + @string_states.flat_map { |from, hash| hash.map { |s, to| [from, s, to] } } + @stdparam_states.map { |from, to| - [from, DEFAULT_EXP_ANCHORED, to] + [from, default_exp_anchored, to] } + @regexp_states.flat_map { |from, hash| - hash.map { |s, to| [from, s, to] } + hash.map { |r, to| [from, r.source.gsub("\\") { "\\\\" }, to] } } end end From 67b8a38f32e3e743cd36704a0d4fdb52dfef1cf4 Mon Sep 17 00:00:00 2001 From: zzak Date: Sun, 7 Sep 2025 11:28:14 +0900 Subject: [PATCH 0582/1075] Fix EventReporter heading level for Security --- activesupport/lib/active_support/event_reporter.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/activesupport/lib/active_support/event_reporter.rb b/activesupport/lib/active_support/event_reporter.rb index 2a091d520160a..adaca45c5e1c4 100644 --- a/activesupport/lib/active_support/event_reporter.rb +++ b/activesupport/lib/active_support/event_reporter.rb @@ -263,7 +263,7 @@ def clear # # payload: { id: 123 }, # # } # - # ==== Security + # === Security # # When reporting events, Hash-based payloads are automatically filtered to remove sensitive data based on {Rails.application.filter_parameters}[https://guides.rubyonrails.org/configuring.html#config-filter-parameters]. # From 24fed7df9db53ae81af095657a2b5db0b2ccd945 Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Mon, 8 Sep 2025 09:16:54 +0200 Subject: [PATCH 0583/1075] Revert "[Fix #53683] Reduce cache time for non-asset files in public dir" --- railties/CHANGELOG.md | 14 ++++++++++++++ .../config/environments/production.rb.tt | 16 ++-------------- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/railties/CHANGELOG.md b/railties/CHANGELOG.md index d746b942d863f..5820e1dc1ebbc 100644 --- a/railties/CHANGELOG.md +++ b/railties/CHANGELOG.md @@ -1,3 +1,17 @@ +* Reverted the incorrect default `config.public_file_server.headers` config. + + If you created a new application using Rails `8.1.0.beta1`, make sure to regenerate + `config/environments/production.rb`, or to manually edit the `config.public_file_server.headers` + configuration to just be: + + ```ruby + # Cache assets for far-future expiry since they are all digest stamped. + config.public_file_server.headers = { "cache-control" => "public, max-age=#{1.year.to_i}" } + ``` + + *Jean Boussier* + + ## Rails 8.1.0.beta1 (September 04, 2025) ## * Add command `rails credentials:fetch PATH` to get the value of a credential from the credentials file. diff --git a/railties/lib/rails/generators/rails/app/templates/config/environments/production.rb.tt b/railties/lib/rails/generators/rails/app/templates/config/environments/production.rb.tt index 0117292a33e2f..5ac606b8a3173 100644 --- a/railties/lib/rails/generators/rails/app/templates/config/environments/production.rb.tt +++ b/railties/lib/rails/generators/rails/app/templates/config/environments/production.rb.tt @@ -17,20 +17,8 @@ Rails.application.configure do config.action_controller.perform_caching = true <%- end -%> - # Cache digest stamped assets for far-future expiry. - # Short cache for others: robots.txt, sitemap.xml, 404.html, etc. - config.public_file_server.headers = { - "cache-control" => lambda do |path, _| - if path.start_with?("/assets/") - # Files in /assets/ are expected to be fully immutable. - # If the content change the URL too. - "public, immutable, max-age=#{1.year.to_i}" - else - # For anything else we cache for 1 minute. - "public, max-age=#{1.minute.to_i}, stale-while-revalidate=#{5.minutes.to_i}" - end - end - } + # Cache assets for far-future expiry since they are all digest stamped. + config.public_file_server.headers = { "cache-control" => "public, max-age=#{1.year.to_i}" } # Enable serving of images, stylesheets, and JavaScripts from an asset server. # config.asset_host = "http://assets.example.com" From eecc362a96b263404edc49c246f657e1f9ab1d67 Mon Sep 17 00:00:00 2001 From: Joaquin Tomas Date: Thu, 14 Aug 2025 13:43:33 -0300 Subject: [PATCH 0584/1075] Fix Auth generator to skip tests unless minitest is used --- .../authentication_generator.rb | 9 ------- .../authentication_generator.rb | 13 ++++++++++ .../previews/passwords_mailer_preview.rb.tt | 0 .../test_helpers/session_test_helper.rb.tt | 0 .../authentication_generator_test.rb | 26 +++++++++++++++++++ 5 files changed, 39 insertions(+), 9 deletions(-) rename railties/lib/rails/generators/{rails => test_unit}/authentication/templates/test/mailers/previews/passwords_mailer_preview.rb.tt (100%) rename railties/lib/rails/generators/{rails => test_unit}/authentication/templates/test/test_helpers/session_test_helper.rb.tt (100%) diff --git a/railties/lib/rails/generators/rails/authentication/authentication_generator.rb b/railties/lib/rails/generators/rails/authentication/authentication_generator.rb index 6defc45987ea9..13caf7cb1ed16 100644 --- a/railties/lib/rails/generators/rails/authentication/authentication_generator.rb +++ b/railties/lib/rails/generators/rails/authentication/authentication_generator.rb @@ -30,11 +30,7 @@ def create_authentication_files template "app/views/passwords_mailer/reset.html.erb" template "app/views/passwords_mailer/reset.text.erb" - - template "test/mailers/previews/passwords_mailer_preview.rb" end - - template "test/test_helpers/session_test_helper.rb" end def configure_application_controller @@ -60,11 +56,6 @@ def add_migrations generate "migration", "CreateSessions", "user:references ip_address:string user_agent:string", "--force" end - def configure_test_helper - inject_into_file "test/test_helper.rb", "require_relative \"test_helpers/session_test_helper\"\n", after: "require \"rails/test_help\"\n" - inject_into_class "test/test_helper.rb", "TestCase", " include SessionTestHelper\n" - end - hook_for :test_framework end end diff --git a/railties/lib/rails/generators/test_unit/authentication/authentication_generator.rb b/railties/lib/rails/generators/test_unit/authentication/authentication_generator.rb index d55ae4775e45d..7357bcd12ac2d 100644 --- a/railties/lib/rails/generators/test_unit/authentication/authentication_generator.rb +++ b/railties/lib/rails/generators/test_unit/authentication/authentication_generator.rb @@ -14,6 +14,19 @@ def create_controller_test_files template "test/controllers/sessions_controller_test.rb" template "test/controllers/passwords_controller_test.rb" end + + def create_mailer_preview_files + template "test/mailers/previews/passwords_mailer_preview.rb" if defined?(ActionMailer::Railtie) + end + + def create_test_helper_files + template "test/test_helpers/session_test_helper.rb" + end + + def configure_test_helper + inject_into_file "test/test_helper.rb", "require_relative \"test_helpers/session_test_helper\"\n", after: "require \"rails/test_help\"\n" + inject_into_class "test/test_helper.rb", "TestCase", " include SessionTestHelper\n" + end end end end diff --git a/railties/lib/rails/generators/rails/authentication/templates/test/mailers/previews/passwords_mailer_preview.rb.tt b/railties/lib/rails/generators/test_unit/authentication/templates/test/mailers/previews/passwords_mailer_preview.rb.tt similarity index 100% rename from railties/lib/rails/generators/rails/authentication/templates/test/mailers/previews/passwords_mailer_preview.rb.tt rename to railties/lib/rails/generators/test_unit/authentication/templates/test/mailers/previews/passwords_mailer_preview.rb.tt diff --git a/railties/lib/rails/generators/rails/authentication/templates/test/test_helpers/session_test_helper.rb.tt b/railties/lib/rails/generators/test_unit/authentication/templates/test/test_helpers/session_test_helper.rb.tt similarity index 100% rename from railties/lib/rails/generators/rails/authentication/templates/test/test_helpers/session_test_helper.rb.tt rename to railties/lib/rails/generators/test_unit/authentication/templates/test/test_helpers/session_test_helper.rb.tt diff --git a/railties/test/generators/authentication_generator_test.rb b/railties/test/generators/authentication_generator_test.rb index 6d5e722cb3493..82a400eef60cb 100644 --- a/railties/test/generators/authentication_generator_test.rb +++ b/railties/test/generators/authentication_generator_test.rb @@ -62,6 +62,9 @@ def test_authentication_generator assert_file "test/fixtures/users.yml" assert_file "test/controllers/sessions_controller_test.rb" assert_file "test/controllers/passwords_controller_test.rb" + assert_file "test/mailers/previews/passwords_mailer_preview.rb" + + assert_file "test/test_helpers/session_test_helper.rb" assert_file "test/test_helper.rb" do |content| assert_match(/session_test_helper/, content) @@ -111,6 +114,9 @@ def test_authentication_generator_with_api_flag assert_file "test/models/user_test.rb" assert_file "test/fixtures/users.yml" + assert_file "test/mailers/previews/passwords_mailer_preview.rb" + + assert_file "test/test_helpers/session_test_helper.rb" assert_file "test/test_helper.rb" do |content| assert_match(/session_test_helper/, content) @@ -127,6 +133,26 @@ def test_model_test_is_skipped_if_test_framework_is_given assert_no_file "test/models/user_test.rb" end + def mailer_preview_is_skipped_if_test_framework_is_given + generator([destination_root], ["-t", "rspec"]) + + run_generator_instance + + assert_no_file "test/mailers/previews/passwords_mailer_preview.rb" + end + + def session_test_helper_is_skipped_if_test_framework_is_given + generator([destination_root], ["-t", "rspec"]) + + run_generator_instance + + assert_no_file "test/test_helpers/session_test_helper.rb" + assert_file "test/test_helper.rb" do |test_helper_content| + assert_no_match(/session_test_helper/, test_helper_content) + assert_no_match(/SessionTestHelper/, test_helper_content) + end + end + def test_connection_class_skipped_without_action_cable old_value = ActionCable.const_get(:Engine) ActionCable.send(:remove_const, :Engine) From 5336601860fa8b9ea4aef35cd713e42fda3fff37 Mon Sep 17 00:00:00 2001 From: Roberto Miranda Date: Sat, 6 Sep 2025 07:54:24 +0200 Subject: [PATCH 0585/1075] Allow disabling action_controller.logger by setting it to nil or false Rails guides state that action_controller.logger can be set to nil to disable logging. Previously, it always defaulted to Rails.logger, ignoring nil or false values. This change uses options.fetch(:logger, Rails.logger), enabling apps to opt out of logging when desired. --- actionpack/CHANGELOG.md | 4 ++ actionpack/lib/action_controller/railtie.rb | 3 +- .../test/application/configuration_test.rb | 44 +++++++++++++++++++ 3 files changed, 50 insertions(+), 1 deletion(-) diff --git a/actionpack/CHANGELOG.md b/actionpack/CHANGELOG.md index ab01224c1c040..1debed627325f 100644 --- a/actionpack/CHANGELOG.md +++ b/actionpack/CHANGELOG.md @@ -1,3 +1,7 @@ +* Allow `action_controller.logger` to be disabled by setting it to `nil` or `false` instead of always defaulting to `Rails.logger`. + + *Roberto Miranda* + ## Rails 8.1.0.beta1 (September 04, 2025) ## * Remove deprecated support to a route to multiple paths. diff --git a/actionpack/lib/action_controller/railtie.rb b/actionpack/lib/action_controller/railtie.rb index 6931cba6c6be2..42af3c95a0b3c 100644 --- a/actionpack/lib/action_controller/railtie.rb +++ b/actionpack/lib/action_controller/railtie.rb @@ -57,7 +57,8 @@ class Railtie < Rails::Railtie # :nodoc: paths = app.config.paths options = app.config.action_controller - options.logger ||= Rails.logger + options.logger = options.fetch(:logger, Rails.logger) + options.cache_store ||= Rails.cache options.javascripts_dir ||= paths["public/javascripts"].first diff --git a/railties/test/application/configuration_test.rb b/railties/test/application/configuration_test.rb index 286a0edbff765..5a52a539eae65 100644 --- a/railties/test/application/configuration_test.rb +++ b/railties/test/application/configuration_test.rb @@ -5059,6 +5059,50 @@ class Foo < ApplicationRecord assert_equal 5, Regexp.timeout end + test "action_controller.logger defaults to Rails.logger" do + restore_default_config + add_to_config "config.logger = Logger.new(STDOUT, level: Logger::INFO)" + app "development" + + output = capture(:stdout) do + get "/" + end + + assert_equal Rails.application.config.action_controller.logger, Rails.logger + assert output.include?("Processing by Rails::WelcomeController#index as HTML") + end + + test "action_controller.logger can be disabled by assigning nil" do + add_to_config <<-RUBY + config.logger = Logger.new(STDOUT, level: Logger::INFO) + config.action_controller.logger = nil + RUBY + app "development" + + output = capture(:stdout) do + get "/" + end + + assert_nil Rails.application.config.action_controller.logger + assert_not output.include?("Processing by Rails::WelcomeController#index as HTML") + end + + test "action_controller.logger can be disabled by assigning false" do + add_to_config <<-RUBY + config.logger = Logger.new(STDOUT, level: Logger::INFO) + config.action_controller.logger = false + RUBY + + app "development" + output = capture(:stdout) do + get "/" + end + + + assert_equal false, Rails.application.config.action_controller.logger + assert_not output.include?("Processing by Rails::WelcomeController#index as HTML") + end + private def set_custom_config(contents, config_source = "custom".inspect) app_file "config/custom.yml", contents From 44da8547144fe1046df9a4e1c8849c460dd7bdc7 Mon Sep 17 00:00:00 2001 From: zzak Date: Mon, 8 Sep 2025 19:58:07 +0900 Subject: [PATCH 0586/1075] Allow removing autocomplete="off" for more tags Co-authored-by: Hartley McGuire --- actionview/CHANGELOG.md | 9 +++-- .../lib/action_view/helpers/tags/check_box.rb | 8 ++++- .../action_view/helpers/tags/file_field.rb | 4 ++- .../helpers/tags/select_renderer.rb | 4 ++- actionview/test/template/form_helper_test.rb | 36 +++++++++++++++++++ .../test/template/form_options_helper_test.rb | 19 ++++++++++ 6 files changed, 75 insertions(+), 5 deletions(-) diff --git a/actionview/CHANGELOG.md b/actionview/CHANGELOG.md index e52c9fb79ed70..3d13fc3847f00 100644 --- a/actionview/CHANGELOG.md +++ b/actionview/CHANGELOG.md @@ -4,8 +4,13 @@ *Ben Sheldon* -* remove `autocomplete="off"` on hidden inputs generated by `form_tag`, `token_tag`, `method_tag`, and the hidden - parameter fields included in `button_to` forms will omit the `autocomplete="off"` attribute. +* Remove `autocomplete="off"` on hidden inputs generated by the following + tags: + + * `form_tag`, `token_tag`, `method_tag` + + As well as the hidden parameter fields included in `button_to`, + `check_box`, `select` (with `multiple`) and `file_field` forms. *nkulway* diff --git a/actionview/lib/action_view/helpers/tags/check_box.rb b/actionview/lib/action_view/helpers/tags/check_box.rb index cf347b5e01f07..86ccaf29d062e 100644 --- a/actionview/lib/action_view/helpers/tags/check_box.rb +++ b/actionview/lib/action_view/helpers/tags/check_box.rb @@ -57,7 +57,13 @@ def checked?(value) end def hidden_field_for_checkbox(options) - @unchecked_value ? tag("input", options.slice("name", "disabled", "form").merge!("type" => "hidden", "value" => @unchecked_value, "autocomplete" => "off")) : "".html_safe + if @unchecked_value + tag_options = options.slice("name", "disabled", "form").merge!("type" => "hidden", "value" => @unchecked_value) + tag_options["autocomplete"] = "off" unless ActionView::Base.remove_hidden_field_autocomplete + tag("input", tag_options) + else + "".html_safe + end end end end diff --git a/actionview/lib/action_view/helpers/tags/file_field.rb b/actionview/lib/action_view/helpers/tags/file_field.rb index 236078ad615b0..adefc0bda5e4c 100644 --- a/actionview/lib/action_view/helpers/tags/file_field.rb +++ b/actionview/lib/action_view/helpers/tags/file_field.rb @@ -18,7 +18,9 @@ def render private def hidden_field_for_multiple_file(options) - tag("input", "name" => options["name"], "type" => "hidden", "value" => "", "autocomplete" => "off") + tag_options = { "name" => options["name"], "type" => "hidden", "value" => "" } + tag_options["autocomplete"] = "off" unless ActionView::Base.remove_hidden_field_autocomplete + tag("input", tag_options) end end end diff --git a/actionview/lib/action_view/helpers/tags/select_renderer.rb b/actionview/lib/action_view/helpers/tags/select_renderer.rb index 10f8451c35e0a..9cf7fce68b4c8 100644 --- a/actionview/lib/action_view/helpers/tags/select_renderer.rb +++ b/actionview/lib/action_view/helpers/tags/select_renderer.rb @@ -22,7 +22,9 @@ def select_content_tag(option_tags, options, html_options) select = content_tag("select", add_options(option_tags, options, value), html_options) if html_options["multiple"] && options.fetch(:include_hidden, true) - tag("input", disabled: html_options["disabled"], name: html_options["name"], type: "hidden", value: "", autocomplete: "off") + select + tag_options = { disabled: html_options["disabled"], name: html_options["name"], type: "hidden", value: "" } + tag_options[:autocomplete] = "off" unless ActionView::Base.remove_hidden_field_autocomplete + tag("input", tag_options) + select else select end diff --git a/actionview/test/template/form_helper_test.rb b/actionview/test/template/form_helper_test.rb index aa7c72f5e5778..d1d325dd97d57 100644 --- a/actionview/test/template/form_helper_test.rb +++ b/actionview/test/template/form_helper_test.rb @@ -3,6 +3,8 @@ require "abstract_unit" require "controller/fake_models" +require "active_support/core_ext/object/with" + class FormHelperTest < ActionView::TestCase include RenderERBUtils @@ -589,6 +591,22 @@ def test_file_field_with_multiple_behavior_and_explicit_name_configured_include_ ActionView::Helpers::FormHelper.multiple_file_field_include_hidden = old_value end + def test_file_field_with_multiple_include_hidden_includes_autocomplete + ActionView::Base.with(remove_hidden_field_autocomplete: false) do + expected = '' \ + '' + assert_dom_equal expected, file_field("import", "file", multiple: true, include_hidden: true) + end + end + + def test_file_field_with_multiple_include_hidden_omits_autocomplete + ActionView::Base.with(remove_hidden_field_autocomplete: true) do + expected = '' \ + '' + assert_dom_equal expected, file_field("import", "file", multiple: true, include_hidden: true) + end + end + def test_file_field_with_direct_upload_when_rails_direct_uploads_url_is_not_defined expected = '' assert_dom_equal expected, file_field("import", "file", direct_upload: true) @@ -817,6 +835,24 @@ def test_checkbox_with_explicit_checked_and_unchecked_values_when_object_value_i ) end + def test_checkbox_with_unchecked_value_hidden_autocomplete + @post.secret = true + assert_dom_equal( + '', + checkbox("post", "secret", {}, true, true) + ) + end + + def test_checkbox_with_unchecked_value_omits_hidden_autocomplete + ActionView::Base.with(remove_hidden_field_autocomplete: true) do + @post.secret = true + assert_dom_equal( + '', + checkbox("post", "secret", {}, true, true) + ) + end + end + def test_checkbox_with_nil_unchecked_value @post.secret = "on" assert_dom_equal( diff --git a/actionview/test/template/form_options_helper_test.rb b/actionview/test/template/form_options_helper_test.rb index 0f68f541c7aea..34b7a0922ba46 100644 --- a/actionview/test/template/form_options_helper_test.rb +++ b/actionview/test/template/form_options_helper_test.rb @@ -2,6 +2,7 @@ require "abstract_unit" require "active_support/core_ext/enumerable" +require "active_support/core_ext/object/with" class Map < Hash def category @@ -754,6 +755,24 @@ def test_select_with_multiple_and_disabled_to_add_disabled_hidden_input ) end + def test_select_with_multiple_and_include_hidden + output_buffer = select(:post, :category, "", { include_hidden: true }, { multiple: true }) + assert_dom_equal( + "", + output_buffer + ) + end + + def test_select_with_multiple_and_include_hidden_omits_autocomplete + ActionView::Base.with(remove_hidden_field_autocomplete: true) do + output_buffer = select(:post, :category, "", { include_hidden: true }, { multiple: true }) + assert_dom_equal( + "", + output_buffer + ) + end + end + def test_select_with_blank @post = Post.new @post.category = "" From 4e9505ecef814cbb0baf2e0bc60ed594689992b4 Mon Sep 17 00:00:00 2001 From: Viktor Schmidt Date: Mon, 8 Sep 2025 22:12:10 +0200 Subject: [PATCH 0587/1075] Fix bootsnap precompilation by adding parallelization flag --- activesupport/lib/active_support/test_case.rb | 3 +++ .../rails/generators/rails/app/templates/Dockerfile.tt | 8 +++++--- railties/test/fixtures/Dockerfile.test | 7 ++++--- railties/test/generators/app_generator_test.rb | 10 ++++++++++ 4 files changed, 22 insertions(+), 6 deletions(-) diff --git a/activesupport/lib/active_support/test_case.rb b/activesupport/lib/active_support/test_case.rb index 58f59d8d97292..42a3b603f0799 100644 --- a/activesupport/lib/active_support/test_case.rb +++ b/activesupport/lib/active_support/test_case.rb @@ -1,5 +1,8 @@ # frozen_string_literal: true +p [:load_test_case] +puts caller + require "minitest" require "active_support/testing/tagged_logging" require "active_support/testing/setup_and_teardown" diff --git a/railties/lib/rails/generators/rails/app/templates/Dockerfile.tt b/railties/lib/rails/generators/rails/app/templates/Dockerfile.tt index 2659ec2828759..33f52a90c058f 100644 --- a/railties/lib/rails/generators/rails/app/templates/Dockerfile.tt +++ b/railties/lib/rails/generators/rails/app/templates/Dockerfile.tt @@ -65,7 +65,8 @@ COPY Gemfile Gemfile.lock vendor ./ RUN bundle install && \ rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git<% if depend_on_bootsnap? -%> && \ - bundle exec bootsnap precompile --gemfile<% end %> + # -j 1 disable parallel compilation to avoid a QEMU bug: https://github.com/rails/bootsnap/issues/495 + bundle exec bootsnap precompile -j 1 --gemfile<% end %> <% if using_node? -%> # Install node modules @@ -83,8 +84,9 @@ RUN bun install --frozen-lockfile COPY . . <% if depend_on_bootsnap? -%> -# Precompile bootsnap code for faster boot times -RUN bundle exec bootsnap precompile app/ lib/ +# Precompile bootsnap code for faster boot times. +# -j 1 disable parallel compilation to avoid a QEMU bug: https://github.com/rails/bootsnap/issues/495 +RUN bundle exec bootsnap precompile -j 1 app/ lib/ <% end -%> <% unless dockerfile_binfile_fixups.empty? -%> diff --git a/railties/test/fixtures/Dockerfile.test b/railties/test/fixtures/Dockerfile.test index 90ee34929d597..c40dbbb80c10f 100644 --- a/railties/test/fixtures/Dockerfile.test +++ b/railties/test/fixtures/Dockerfile.test @@ -27,13 +27,14 @@ RUN apt-get install --no-install-recommends -y build-essential git pkg-config COPY Gemfile Gemfile.lock ./ RUN bundle install && \ rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \ - bundle exec bootsnap precompile --gemfile + # -j 1 disable parallel compilation to avoid a QEMU bug: https://github.com/rails/bootsnap/issues/495 + bundle exec bootsnap precompile -j 1 --gemfile # Copy application code COPY . . -# Precompile bootsnap code for faster boot times -RUN bundle exec bootsnap precompile app/ lib/ +# Precompile bootsnap code for faster boot times (single-threaded to mitigate Buildx/QEMU hang) +RUN bundle exec bootsnap precompile -j 1 app/ lib/ # Precompiling assets for production without requiring secret RAILS_MASTER_KEY RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile diff --git a/railties/test/generators/app_generator_test.rb b/railties/test/generators/app_generator_test.rb index 7b8d2d7265fb1..7c675d8b33bf4 100644 --- a/railties/test/generators/app_generator_test.rb +++ b/railties/test/generators/app_generator_test.rb @@ -1120,6 +1120,16 @@ def test_bootsnap_with_dev_option end end + def test_dockerfile_bootsnap_precompile_is_single_threaded + skip "bootsnap not included on JRuby" if defined?(JRUBY_VERSION) + run_generator [destination_root, "--no-skip-bootsnap"] + assert_gem "bootsnap" + assert_file "Dockerfile" do |content| + assert_match(/bootsnap precompile -j 1 --gemfile/, content) + assert_match(/bootsnap precompile -j 1 app\/ lib\//, content) + end + end + def test_inclusion_of_ruby_version run_generator From 604e8e057020d3ae522f674e3fdcaf4dea160d31 Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Tue, 9 Sep 2025 09:53:28 +0200 Subject: [PATCH 0588/1075] Remove mistakenly committed debug statements --- activesupport/lib/active_support/test_case.rb | 3 --- 1 file changed, 3 deletions(-) diff --git a/activesupport/lib/active_support/test_case.rb b/activesupport/lib/active_support/test_case.rb index 42a3b603f0799..58f59d8d97292 100644 --- a/activesupport/lib/active_support/test_case.rb +++ b/activesupport/lib/active_support/test_case.rb @@ -1,8 +1,5 @@ # frozen_string_literal: true -p [:load_test_case] -puts caller - require "minitest" require "active_support/testing/tagged_logging" require "active_support/testing/setup_and_teardown" From ee99d6cb0b35bd6fedaaf7c3b6edf6e3adf0aced Mon Sep 17 00:00:00 2001 From: Amr El Bakry Date: Tue, 9 Sep 2025 15:14:22 +0200 Subject: [PATCH 0589/1075] Update upgrading_ruby_on_rails.md --- guides/source/upgrading_ruby_on_rails.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/guides/source/upgrading_ruby_on_rails.md b/guides/source/upgrading_ruby_on_rails.md index 1577d73495eba..e4f16282f159b 100644 --- a/guides/source/upgrading_ruby_on_rails.md +++ b/guides/source/upgrading_ruby_on_rails.md @@ -461,7 +461,7 @@ Rails 7.1 changes the acceptable values from `true` and `false` to `:all`, `:res * `:rescuable` - render HTML error pages for exceptions declared by [`config.action_dispatch.rescue_responses`](/configuring.html#config-action-dispatch-rescue-responses) * `:none` (equivalent to `false`) - do not rescue from any exceptions -Applications generated by Rails 7.1 or later set `config.action_dispatch.show_exceptions = :rescuable` in their `config/environments/test.rb`. When upgrading, existing applications can change `config.action_dispatch.show_exceptions = :rescuable` to utilize the new behavior, or replace the old values with the corresponding new ones (`true` replaces `:all`, `false` replaces `:none`). +Applications generated by Rails 7.1 or later set `config.action_dispatch.show_exceptions = :rescuable` in their `config/environments/test.rb`. When upgrading, existing applications can change `config.action_dispatch.show_exceptions = :rescuable` to utilize the new behavior, or replace the old values with the corresponding new ones (`:all` replaces `true`, `:none` replaces `false`). Upgrading from Rails 6.1 to Rails 7.0 ------------------------------------- From f547a17658be630ab8df1d2b664237477051f547 Mon Sep 17 00:00:00 2001 From: Carl Eqladios Date: Tue, 9 Sep 2025 22:01:24 +0000 Subject: [PATCH 0590/1075] Do not use db:test:prepare in ci template if skip_active_record is on - in a --skip-active-record generation, using db:test:prepare will throw Rails::Command::UnrecognizedCommandError --- .../rails/app/templates/github/ci.yml.tt | 4 ++-- .../test/generators/app_generator_test.rb | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/railties/lib/rails/generators/rails/app/templates/github/ci.yml.tt b/railties/lib/rails/generators/rails/app/templates/github/ci.yml.tt index 7ea797fa926c1..99a5ed7829780 100644 --- a/railties/lib/rails/generators/rails/app/templates/github/ci.yml.tt +++ b/railties/lib/rails/generators/rails/app/templates/github/ci.yml.tt @@ -142,7 +142,7 @@ jobs: <%- end -%> # RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }} # REDIS_URL: redis://localhost:6379/0 - run: bin/rails db:test:prepare test + run: bin/rails <%= skip_active_record? ? "" : "db:test:prepare " %>test <%- unless options[:api] || options[:skip_system_test] -%> system-test: @@ -215,7 +215,7 @@ jobs: <%- end -%> # RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }} # REDIS_URL: redis://localhost:6379/0 - run: bin/rails db:test:prepare test:system + run: bin/rails <%= skip_active_record? ? "" : "db:test:prepare " %>test:system - name: Keep screenshots from failed system tests uses: actions/upload-artifact@v4 diff --git a/railties/test/generators/app_generator_test.rb b/railties/test/generators/app_generator_test.rb index 7c675d8b33bf4..9eeee64452e78 100644 --- a/railties/test/generators/app_generator_test.rb +++ b/railties/test/generators/app_generator_test.rb @@ -669,6 +669,25 @@ def test_inclusion_of_ci_files end end + def test_ci_workflow_includes_db_test_prepare_by_default + run_generator + assert_file ".github/workflows/ci.yml" do |content| + assert_match(/db:test:prepare test/, content) + assert_match(/db:test:prepare test:system/, content) + end + end + + def test_ci_workflow_does_not_include_db_test_prepare_when_skip_active_record_is_given + run_generator [destination_root, "--skip-active-record"] + run_app_update + + assert_file ".github/workflows/ci.yml" do |content| + assert_no_match(/db:test:prepare/, content) + assert_match(/bin\/rails test/, content) + assert_match(/bin\/rails test:system/, content) + end + end + def test_ci_files_are_skipped_if_required run_generator [destination_root, "--skip-ci"] From c22750a40f8801aa365f481cd15632e9deae1f6b Mon Sep 17 00:00:00 2001 From: Guillermo Iguaran Date: Tue, 9 Sep 2025 15:17:47 -0700 Subject: [PATCH 0591/1075] Add fetchpriority to Link headers to match the generated HTML by preload_link_tag. Closes #55561 --- .../action_view/helpers/asset_tag_helper.rb | 3 +++ .../test/template/asset_tag_helper_test.rb | 25 ++++++++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/actionview/lib/action_view/helpers/asset_tag_helper.rb b/actionview/lib/action_view/helpers/asset_tag_helper.rb index bca6a602e557b..d3061141b0bc8 100644 --- a/actionview/lib/action_view/helpers/asset_tag_helper.rb +++ b/actionview/lib/action_view/helpers/asset_tag_helper.rb @@ -368,6 +368,7 @@ def preload_link_tag(source, options = {}) crossorigin = options.delete(:crossorigin) crossorigin = "anonymous" if crossorigin == true || (crossorigin.blank? && as_type == "font") integrity = options[:integrity] + fetchpriority = options.delete(:fetchpriority) nopush = options.delete(:nopush) || false rel = mime_type == "module" ? "modulepreload" : "preload" add_nonce = content_security_policy_nonce && @@ -384,11 +385,13 @@ def preload_link_tag(source, options = {}) as: as_type, type: mime_type, crossorigin: crossorigin, + fetchpriority: fetchpriority, **options.symbolize_keys) preload_link = "<#{href}>; rel=#{rel}; as=#{as_type}" preload_link += "; type=#{mime_type}" if mime_type preload_link += "; crossorigin=#{crossorigin}" if crossorigin + preload_link += "; fetchpriority=#{fetchpriority}" if fetchpriority preload_link += "; integrity=#{integrity}" if integrity preload_link += "; nonce=#{content_security_policy_nonce}" if add_nonce preload_link += "; nopush" if nopush diff --git a/actionview/test/template/asset_tag_helper_test.rb b/actionview/test/template/asset_tag_helper_test.rb index 7abe73007b64d..213be4d3fdb08 100644 --- a/actionview/test/template/asset_tag_helper_test.rb +++ b/actionview/test/template/asset_tag_helper_test.rb @@ -347,7 +347,10 @@ def content_security_policy_nonce %(preload_link_tag '/media/audio.ogg', nopush: true) => %(), %(preload_link_tag '/style.css', integrity: 'sha256-AbpHGcgLb+kRsJGnwFEktk7uzpZOCcBY74+YBdrKVGs') => %(), %(preload_link_tag '/sprite.svg') => %(), - %(preload_link_tag '/mb-icon.png') => %() + %(preload_link_tag '/mb-icon.png') => %(), + %(preload_link_tag '/hero-image.jpg', fetchpriority: 'high') => %(), + %(preload_link_tag '/critical.css', fetchpriority: 'high') => %(), + %(preload_link_tag '/background.png', fetchpriority: 'low') => %() } VideoPathToTag = { @@ -791,6 +794,26 @@ def test_should_not_preload_links_when_disabled end end + def test_should_set_preload_links_with_fetchpriority + with_preload_links_header do + preload_link_tag("http://example.com/hero.jpg", fetchpriority: "high") + preload_link_tag("http://example.com/background.png", fetchpriority: "low") + expected = "; rel=preload; as=image; type=image/jpeg; fetchpriority=high,; rel=preload; as=image; type=image/png; fetchpriority=low" + assert_equal expected, @response.headers["link"] + end + end + + def test_preload_link_tag_fetchpriority_html_and_header_consistency + with_preload_links_header do + html_tag = preload_link_tag("http://example.com/critical.css", fetchpriority: "high") + # Verify HTML tag includes fetchpriority + assert_match(/fetchpriority="high"/, html_tag) + + expected_header = "; rel=preload; as=style; type=text/css; fetchpriority=high" + assert_equal expected_header, @response.headers["link"] + end + end + def test_image_path ImagePathToTag.each { |method, tag| assert_dom_equal(tag, eval(method)) } end From addc45c5b4242a899daf4bea02038a8a29c18556 Mon Sep 17 00:00:00 2001 From: Hartley McGuire Date: Tue, 9 Sep 2025 19:29:40 -0400 Subject: [PATCH 0592/1075] Add test for url_for port normalization --- actionpack/test/controller/url_for_test.rb | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/actionpack/test/controller/url_for_test.rb b/actionpack/test/controller/url_for_test.rb index 384e0d2f98d61..8df6c64159304 100644 --- a/actionpack/test/controller/url_for_test.rb +++ b/actionpack/test/controller/url_for_test.rb @@ -172,6 +172,28 @@ def test_port ) end + def test_port_normalization + assert_equal( + "https://example.com/c", + W.new.url_for(host: "https://example.com:443", controller: "c") + ) + + assert_equal( + "https://example.com:444/c", + W.new.url_for(host: "https://example.com:444", controller: "c") + ) + + assert_equal( + "http://example.com/c", + W.new.url_for(host: "http://example.com:80", controller: "c") + ) + + assert_equal( + "http://example.com:81/c", + W.new.url_for(host: "http://example.com:81", controller: "c") + ) + end + def test_default_port add_host! add_port! From 11347a44c4025e658ad46680fdcde2903ca5f94b Mon Sep 17 00:00:00 2001 From: zzak Date: Wed, 10 Sep 2025 08:30:22 +0900 Subject: [PATCH 0593/1075] Add changelogs for c22750a and f56f08e --- actionview/CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/actionview/CHANGELOG.md b/actionview/CHANGELOG.md index 3d13fc3847f00..10f617fa40161 100644 --- a/actionview/CHANGELOG.md +++ b/actionview/CHANGELOG.md @@ -1,5 +1,13 @@ +* Add `fetchpriority` to Link headers to match HTML generated by `preload_link_tag`. + + *Guillermo Iguaran* + ## Rails 8.1.0.beta1 (September 04, 2025) ## +* Add CSP `nonce` to Link headers generated by `preload_link_tag`. + + *Alexander Gitter* + * Allow `current_page?` to match against specific HTTP method(s) with a `method:` option. *Ben Sheldon* From 7705fb0141854782d17b862dbcbbaeba5a914f0a Mon Sep 17 00:00:00 2001 From: Guillermo Iguaran Date: Tue, 9 Sep 2025 17:53:46 -0700 Subject: [PATCH 0594/1075] Apply suggestions from code review Co-authored-by: Hartley McGuire --- .../lib/rails/generators/rails/app/templates/github/ci.yml.tt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/railties/lib/rails/generators/rails/app/templates/github/ci.yml.tt b/railties/lib/rails/generators/rails/app/templates/github/ci.yml.tt index 99a5ed7829780..bf1b0163ad41b 100644 --- a/railties/lib/rails/generators/rails/app/templates/github/ci.yml.tt +++ b/railties/lib/rails/generators/rails/app/templates/github/ci.yml.tt @@ -142,7 +142,7 @@ jobs: <%- end -%> # RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }} # REDIS_URL: redis://localhost:6379/0 - run: bin/rails <%= skip_active_record? ? "" : "db:test:prepare " %>test + run: bin/rails <%= "db:test:prepare " unless skip_active_record? %>test <%- unless options[:api] || options[:skip_system_test] -%> system-test: @@ -215,7 +215,7 @@ jobs: <%- end -%> # RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }} # REDIS_URL: redis://localhost:6379/0 - run: bin/rails <%= skip_active_record? ? "" : "db:test:prepare " %>test:system + run: bin/rails <%= "db:test:prepare " unless skip_active_record? %>test:system - name: Keep screenshots from failed system tests uses: actions/upload-artifact@v4 From 032a286244c7d296e6508d84861ecedf12d133aa Mon Sep 17 00:00:00 2001 From: Guillermo Iguaran Date: Tue, 9 Sep 2025 18:17:54 -0700 Subject: [PATCH 0595/1075] Fix assertion args order --- railties/test/application/configuration_test.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/railties/test/application/configuration_test.rb b/railties/test/application/configuration_test.rb index 5a52a539eae65..99774c2c131e3 100644 --- a/railties/test/application/configuration_test.rb +++ b/railties/test/application/configuration_test.rb @@ -5068,7 +5068,7 @@ class Foo < ApplicationRecord get "/" end - assert_equal Rails.application.config.action_controller.logger, Rails.logger + assert_equal Rails.logger, Rails.application.config.action_controller.logger assert output.include?("Processing by Rails::WelcomeController#index as HTML") end From 941fbe2a057f825533fdc87b16d2a6a104051ece Mon Sep 17 00:00:00 2001 From: Saiqul Haq Date: Wed, 10 Sep 2025 10:52:50 +0700 Subject: [PATCH 0596/1075] feat(deploy.yml.tt): add conditional asset_path configuration for API apps to prevent 404 errors on in-flight requests test(api_app_generator_test.rb): add test to ensure asset_path is excluded in deploy.yml for API apps test(app_generator_test.rb): add test to ensure asset_path is included in deploy.yml for regular apps --- railties/CHANGELOG.md | 8 ++++++++ .../rails/app/templates/config/deploy.yml.tt | 3 +++ railties/test/generators/api_app_generator_test.rb | 10 ++++++++++ railties/test/generators/app_generator_test.rb | 9 +++++++++ 4 files changed, 30 insertions(+) diff --git a/railties/CHANGELOG.md b/railties/CHANGELOG.md index 5820e1dc1ebbc..530b0782f195c 100644 --- a/railties/CHANGELOG.md +++ b/railties/CHANGELOG.md @@ -1,3 +1,11 @@ +* Exclude `asset_path` configuration from Kamal `deploy.yml` for API applications. + + API applications don't serve assets, so the `asset_path` configuration in `deploy.yml` + is not needed and can cause 404 errors on in-flight requests. The asset_path is now + only included for regular Rails applications that serve assets. + + *Saiqul Haq* + * Reverted the incorrect default `config.public_file_server.headers` config. If you created a new application using Rails `8.1.0.beta1`, make sure to regenerate diff --git a/railties/lib/rails/generators/rails/app/templates/config/deploy.yml.tt b/railties/lib/rails/generators/rails/app/templates/config/deploy.yml.tt index f7248fd24303f..d011b8eb1063b 100644 --- a/railties/lib/rails/generators/rails/app/templates/config/deploy.yml.tt +++ b/railties/lib/rails/generators/rails/app/templates/config/deploy.yml.tt @@ -80,11 +80,14 @@ volumes: - "<%= app_name %>_storage:/rails/storage" <% end -%> +<% unless options.api? -%> # Bridge fingerprinted assets, like JS and CSS, between versions to avoid # hitting 404 on in-flight requests. Combines all files from new and old # version inside the asset_path. asset_path: /rails/public/assets +<% end -%> + # Configure the image builder. builder: arch: amd64 diff --git a/railties/test/generators/api_app_generator_test.rb b/railties/test/generators/api_app_generator_test.rb index ae7620e07a094..7686c81fa7464 100644 --- a/railties/test/generators/api_app_generator_test.rb +++ b/railties/test/generators/api_app_generator_test.rb @@ -140,6 +140,16 @@ def test_app_update_does_not_generate_public_files assert_no_file "public/406-unsupported-browser.html" end + def test_kamal_deploy_yml_excludes_asset_path_for_api_apps + generator [destination_root], ["--api"] + run_generator_instance + + assert_file "config/deploy.yml" do |content| + assert_no_match(/asset_path:/, content) + assert_no_match(/public\/assets/, content) + end + end + private def default_files %w(.gitignore diff --git a/railties/test/generators/app_generator_test.rb b/railties/test/generators/app_generator_test.rb index 9eeee64452e78..eefbfab1a2341 100644 --- a/railties/test/generators/app_generator_test.rb +++ b/railties/test/generators/app_generator_test.rb @@ -775,6 +775,15 @@ def test_kamal_storage_volume_is_skipped_if_required end end + def test_kamal_deploy_yml_includes_asset_path_for_regular_apps + generator [destination_root] + run_generator_instance + + assert_file "config/deploy.yml" do |content| + assert_match(/asset_path: \/rails\/public\/assets/, content) + end + end + def test_usage_read_from_file assert_called(File, :read, returns: "USAGE FROM FILE") do assert_equal "USAGE FROM FILE", Rails::Generators::AppGenerator.desc From 657849e778c7622d21b13dd1f5a4dcfd271b4d80 Mon Sep 17 00:00:00 2001 From: Jerome Dalbert Date: Mon, 8 Sep 2025 23:48:53 -0700 Subject: [PATCH 0597/1075] Use American English Use American English spelling instead of British English, as per the guidelines: https://guides.rubyonrails.org/api_documentation_guidelines.html#american-english --- .../abstract/connection_pool.rb | 2 +- .../lib/active_record/model_schema.rb | 4 +- .../active_record/application_record/USAGE | 2 +- activerecord/test/assets/schema_dump_5_1.yml | 2 +- .../postgresql/postgresql_adapter_test.rb | 10 +- .../cases/associations/deprecation_test.rb | 16 +-- .../has_many_associations_test.rb | 52 +++++----- activerecord/test/cases/counter_cache_test.rb | 16 +-- activerecord/test/cases/finder_test.rb | 18 ++-- .../test/cases/nested_attributes_test.rb | 12 +-- activerecord/test/cases/relations_test.rb | 98 +++++++++---------- .../test/cases/transaction_callbacks_test.rb | 6 +- activerecord/test/fixtures/cars.yml | 4 +- activerecord/test/models/car.rb | 4 +- activerecord/test/models/dats.rb | 2 +- activerecord/test/models/dats/car.rb | 6 +- .../test/models/dats/{tyre.rb => tire.rb} | 2 +- activerecord/test/models/{tyre.rb => tire.rb} | 4 +- activerecord/test/schema/schema.rb | 4 +- guides/assets/javascripts/guides.js | 6 +- guides/assets/stylesrc/_main.scss | 4 +- guides/source/configuring.md | 2 +- guides/source/generators.md | 4 +- guides/source/upgrading_ruby_on_rails.md | 2 +- 24 files changed, 141 insertions(+), 141 deletions(-) rename activerecord/test/models/dats/{tyre.rb => tire.rb} (67%) rename activerecord/test/models/{tyre.rb => tire.rb} (81%) diff --git a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb index 5c4e0b012c00a..36cf2521ec18d 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb @@ -929,7 +929,7 @@ def build_async_executor # Each connection will only be processed once per call to this method, # but (particularly in the async case) there is no protection against # a second call to this method starting to work through the list - # before the first call has completed. (Though regular pool behaviour + # before the first call has completed. (Though regular pool behavior # will prevent two instances from working on the same specific # connection at the same time.) def sequential_maintenance(candidate_selector, &maintenance_work) diff --git a/activerecord/lib/active_record/model_schema.rb b/activerecord/lib/active_record/model_schema.rb index de5fcbbeb2e52..35f3f52822eb2 100644 --- a/activerecord/lib/active_record/model_schema.rb +++ b/activerecord/lib/active_record/model_schema.rb @@ -48,7 +48,7 @@ module ModelSchema # way of creating a namespace for tables in a shared database. By default, the prefix is the # empty string. # - # If you are organising your models within modules you can add a prefix to the models within + # If you are organizing your models within modules you can add a prefix to the models within # a namespace by defining a singleton method in the parent module called table_name_prefix which # returns your chosen prefix. @@ -65,7 +65,7 @@ module ModelSchema # Works like +table_name_prefix=+, but appends instead of prepends (set to "_basecamp" gives "projects_basecamp", # "people_basecamp"). By default, the suffix is the empty string. # - # If you are organising your models within modules, you can add a suffix to the models within + # If you are organizing your models within modules, you can add a suffix to the models within # a namespace by defining a singleton method in the parent module called table_name_suffix which # returns your chosen suffix. diff --git a/activerecord/lib/rails/generators/active_record/application_record/USAGE b/activerecord/lib/rails/generators/active_record/application_record/USAGE index 730dcb6fc7943..b406763b54b17 100644 --- a/activerecord/lib/rails/generators/active_record/application_record/USAGE +++ b/activerecord/lib/rails/generators/active_record/application_record/USAGE @@ -5,4 +5,4 @@ Example: `bin/rails generate application_record` This generates the base class. A test is not generated because no - behaviour is included in `ApplicationRecord` by default. + behavior is included in `ApplicationRecord` by default. diff --git a/activerecord/test/assets/schema_dump_5_1.yml b/activerecord/test/assets/schema_dump_5_1.yml index 9563b7e100526..5ed5d3315700b 100644 --- a/activerecord/test/assets/schema_dump_5_1.yml +++ b/activerecord/test/assets/schema_dump_5_1.yml @@ -307,7 +307,7 @@ data_sources: traffic_lights: true treasures: true tuning_pegs: true - tyres: true + tires: true variants: true vertices: true warehouse-things: true diff --git a/activerecord/test/cases/adapters/postgresql/postgresql_adapter_test.rb b/activerecord/test/cases/adapters/postgresql/postgresql_adapter_test.rb index 73983d0dc17c9..6d3e6a39e9f48 100644 --- a/activerecord/test/cases/adapters/postgresql/postgresql_adapter_test.rb +++ b/activerecord/test/cases/adapters/postgresql/postgresql_adapter_test.rb @@ -653,7 +653,7 @@ def test_extensions_includes_non_current_schema_name @connection.execute("DROP EXTENSION IF EXISTS hstore") end - def test_ignores_warnings_when_behaviour_ignore + def test_ignores_warnings_when_behavior_ignore with_db_warnings_action(:ignore) do # libpq prints a warning to stderr from C, so we need to stub # the whole file descriptors, not just Ruby's $stdout/$stderr. @@ -665,7 +665,7 @@ def test_ignores_warnings_when_behaviour_ignore end end - def test_logs_warnings_when_behaviour_log + def test_logs_warnings_when_behavior_log with_db_warnings_action(:log) do sql_warning = "[ActiveRecord::SQLWarning] PostgreSQL SQL warning (01000)" @@ -675,7 +675,7 @@ def test_logs_warnings_when_behaviour_log end end - def test_raises_warnings_when_behaviour_raise + def test_raises_warnings_when_behavior_raise with_db_warnings_action(:raise) do error = assert_raises(ActiveRecord::SQLWarning) do @connection.execute("do $$ BEGIN RAISE WARNING 'PostgreSQL SQL warning'; END; $$") @@ -684,7 +684,7 @@ def test_raises_warnings_when_behaviour_raise end end - def test_reports_when_behaviour_report + def test_reports_when_behavior_report with_db_warnings_action(:report) do error_reporter = ActiveSupport::ErrorReporter.new subscriber = ActiveSupport::ErrorReporter::TestHelper::ErrorSubscriber.new @@ -700,7 +700,7 @@ def test_reports_when_behaviour_report end end - def test_warnings_behaviour_can_be_customized_with_a_proc + def test_warnings_behavior_can_be_customized_with_a_proc warning_message = nil warning_level = nil warning_action = ->(warning) do diff --git a/activerecord/test/cases/associations/deprecation_test.rb b/activerecord/test/cases/associations/deprecation_test.rb index 1145d24f8e722..009ea579fb537 100644 --- a/activerecord/test/cases/associations/deprecation_test.rb +++ b/activerecord/test/cases/associations/deprecation_test.rb @@ -34,7 +34,7 @@ def teardown end def assert_message(message, line) - re = /The association DATS::Car#deprecated_tyres is deprecated, the method deprecated_tyres was invoked \(#{__FILE__}:#{line}:in/ + re = /The association DATS::Car#deprecated_tires is deprecated, the method deprecated_tires was invoked \(#{__FILE__}:#{line}:in/ assert_match re, message end end @@ -123,7 +123,7 @@ def teardown end test "report warns in :warn mode" do - DATS::Car.new.deprecated_tyres + DATS::Car.new.deprecated_tires assert_message @io.string, __LINE__ - 1 end end @@ -146,7 +146,7 @@ def teardown test "report warns in :warn mode" do line = __LINE__ + 1 - DATS::Car.new.deprecated_tyres + DATS::Car.new.deprecated_tires assert_message @io.string, line assert_includes @io.string, "\t#{__FILE__}:#{line}:in" @@ -168,7 +168,7 @@ def teardown end test "report does not assume the logger is present" do - assert_nothing_raised { DATS::Car.new.deprecated_tyres } + assert_nothing_raised { DATS::Car.new.deprecated_tires } end end @@ -179,7 +179,7 @@ def setup end test "report raises an error in :raise mode" do - error = assert_raises(ActiveRecord::DeprecatedAssociationError) { DATS::Car.new.deprecated_tyres } + error = assert_raises(ActiveRecord::DeprecatedAssociationError) { DATS::Car.new.deprecated_tires } assert_message error.message, __LINE__ - 1 end end @@ -193,7 +193,7 @@ def setup test "report raises an error in :raise mode" do line = __LINE__ + 1 - error = assert_raises(ActiveRecord::DeprecatedAssociationError) { DATS::Car.new.deprecated_tyres } + error = assert_raises(ActiveRecord::DeprecatedAssociationError) { DATS::Car.new.deprecated_tires } assert_message error.message, line assert_includes error.backtrace.last, "#{__FILE__}:#{line}:in" @@ -230,13 +230,13 @@ def assert_user_facing_reflection(model, association) line = __LINE__ + 2 ActiveSupport::Notifications.subscribed(callback, "deprecated_association.active_record") do - DATS::Car.new.deprecated_tyres + DATS::Car.new.deprecated_tires end assert_equal 1, payloads.size payload = payloads.first - assert_equal DATS::Car.reflect_on_association(:deprecated_tyres), payload[:reflection] + assert_equal DATS::Car.reflect_on_association(:deprecated_tires), payload[:reflection] assert_message payload[:message], line diff --git a/activerecord/test/cases/associations/has_many_associations_test.rb b/activerecord/test/cases/associations/has_many_associations_test.rb index 9aa0cb4046484..20ecd8c5e00be 100644 --- a/activerecord/test/cases/associations/has_many_associations_test.rb +++ b/activerecord/test/cases/associations/has_many_associations_test.rb @@ -36,7 +36,7 @@ require "models/ship_part" require "models/treasure" require "models/parrot" -require "models/tyre" +require "models/tire" require "models/subscriber" require "models/subscription" require "models/zine" @@ -2910,17 +2910,17 @@ def test_association_with_rewhere_doesnt_set_inverse_instance_key test "associations autosaves when object is already persisted" do bulb = Bulb.create! - tyre = Tyre.create! + tire = Tire.create! car = Car.create!(name: "honda") do |c| c.bulbs << bulb - c.tyres << tyre + c.tires << tire end assert_equal [nil, "honda"], car.saved_change_to_name assert_equal 1, car.bulbs.count - assert_equal 1, car.tyres.count + assert_equal 1, car.tires.count end test "associations replace in memory when records have the same id" do @@ -3297,60 +3297,60 @@ class DeprecatedHasManyAssociationsTest < ActiveRecord::TestCase end test "" do - assert_not_deprecated_association(:tyres) do - @car.tyres + assert_not_deprecated_association(:tires) do + @car.tires end - assert_deprecated_association(:deprecated_tyres, context: context_for_method(:deprecated_tyres)) do - assert_equal @car.tyres, @car.deprecated_tyres + assert_deprecated_association(:deprecated_tires, context: context_for_method(:deprecated_tires)) do + assert_equal @car.tires, @car.deprecated_tires end end test "=" do - tyre = DATS::Tyre.new + tire = DATS::Tire.new - assert_not_deprecated_association(:tyres) do - @car.tyres = [tyre] + assert_not_deprecated_association(:tires) do + @car.tires = [tire] end - assert_deprecated_association(:deprecated_tyres, context: context_for_method(:deprecated_tyres=)) do - @car.deprecated_tyres = [tyre] + assert_deprecated_association(:deprecated_tires, context: context_for_method(:deprecated_tires=)) do + @car.deprecated_tires = [tire] end - assert_equal [tyre], @car.deprecated_tyres + assert_equal [tire], @car.deprecated_tires end test "_ids" do - assert_not_deprecated_association(:tyres) do - @car.tyre_ids + assert_not_deprecated_association(:tires) do + @car.tire_ids end - assert_deprecated_association(:deprecated_tyres, context: context_for_method(:deprecated_tyre_ids)) do - assert_equal @car.tyre_ids, @car.deprecated_tyre_ids + assert_deprecated_association(:deprecated_tires, context: context_for_method(:deprecated_tire_ids)) do + assert_equal @car.tire_ids, @car.deprecated_tire_ids end end test "_ids=" do - tyre = @car.tyres.create! + tire = @car.tires.create! - assert_not_deprecated_association(:tyres) do - @car.tyre_ids = [tyre.id] + assert_not_deprecated_association(:tires) do + @car.tire_ids = [tire.id] end - assert_deprecated_association(:deprecated_tyres, context: context_for_method(:deprecated_tyre_ids=)) do - @car.deprecated_tyre_ids = [tyre.id] + assert_deprecated_association(:deprecated_tires, context: context_for_method(:deprecated_tire_ids=)) do + @car.deprecated_tire_ids = [tire.id] end - assert_equal [tyre.id], @car.deprecated_tyre_ids + assert_equal [tire.id], @car.deprecated_tire_ids end test "destroy (not deprecated)" do - assert_not_deprecated_association(:tyres) do + assert_not_deprecated_association(:tires) do @car.destroy end assert_predicate @car, :destroyed? end test "destroy (deprecated)" do - assert_deprecated_association(:deprecated_tyres, context: context_for_dependent) do + assert_deprecated_association(:deprecated_tires, context: context_for_dependent) do @car.destroy end assert_predicate @car, :destroyed? diff --git a/activerecord/test/cases/counter_cache_test.rb b/activerecord/test/cases/counter_cache_test.rb index f6b8770d5829b..1d7ed501d88f6 100644 --- a/activerecord/test/cases/counter_cache_test.rb +++ b/activerecord/test/cases/counter_cache_test.rb @@ -8,7 +8,7 @@ require "models/aircraft" require "models/wheel" require "models/engine" -require "models/tyre" +require "models/tire" require "models/reply" require "models/category" require "models/categorization" @@ -471,21 +471,21 @@ class ::SpecialReply < ::Reply test "active counter cache" do car = Car.new - car.tyres = [Tyre.new, Tyre.new] + car.tires = [Tire.new, Tire.new] car.save! - assert_equal 2, car.custom_tyres_count + assert_equal 2, car.custom_tires_count car.reload assert_no_queries do - assert_equal 2, car.tyres.size - assert_not_predicate car.tyres, :empty? - assert_predicate car.tyres, :any? - assert_not_predicate car.tyres, :none? + assert_equal 2, car.tires.size + assert_not_predicate car.tires, :empty? + assert_predicate car.tires, :any? + assert_not_predicate car.tires, :none? end assert_queries_count(1) do - assert_equal 2, car.tyres.count + assert_equal 2, car.tires.count end end diff --git a/activerecord/test/cases/finder_test.rb b/activerecord/test/cases/finder_test.rb index f51146b1e6ac0..9e5b7bfe4de6f 100644 --- a/activerecord/test/cases/finder_test.rb +++ b/activerecord/test/cases/finder_test.rb @@ -21,7 +21,7 @@ require "models/dog_lover" require "models/dog" require "models/car" -require "models/tyre" +require "models/tire" require "models/subscriber" require "models/non_primary_key" require "models/clothing_item" @@ -1953,21 +1953,21 @@ def test_finder_with_offset_string test "find on a scope does not perform statement caching" do honda = cars(:honda) zyke = cars(:zyke) - tyre = honda.tyres.create! - tyre2 = zyke.tyres.create! + tire = honda.tires.create! + tire2 = zyke.tires.create! - assert_equal tyre, honda.tyres.custom_find(tyre.id) - assert_equal tyre2, zyke.tyres.custom_find(tyre2.id) + assert_equal tire, honda.tires.custom_find(tire.id) + assert_equal tire2, zyke.tires.custom_find(tire2.id) end test "find_by on a scope does not perform statement caching" do honda = cars(:honda) zyke = cars(:zyke) - tyre = honda.tyres.create! - tyre2 = zyke.tyres.create! + tire = honda.tires.create! + tire2 = zyke.tires.create! - assert_equal tyre, honda.tyres.custom_find_by(id: tyre.id) - assert_equal tyre2, zyke.tyres.custom_find_by(id: tyre2.id) + assert_equal tire, honda.tires.custom_find_by(id: tire.id) + assert_equal tire2, zyke.tires.custom_find_by(id: tire2.id) end test "#skip_query_cache! for #exists?" do diff --git a/activerecord/test/cases/nested_attributes_test.rb b/activerecord/test/cases/nested_attributes_test.rb index 07fc968bc24ee..3c2c40e11fec6 100644 --- a/activerecord/test/cases/nested_attributes_test.rb +++ b/activerecord/test/cases/nested_attributes_test.rb @@ -1247,20 +1247,20 @@ class NestedAttributesForDeprecatedAssociationsTest < ActiveRecord::TestCase setup do @model = DATS::Car @car = @model.first - @tyre_attributes = {} + @tire_attributes = {} @bulb_attributes = { "name" => "name for deprecated nested attributes" } end test "has_many" do - assert_not_deprecated_association(:tyres) do - @car.tyres_attributes = [@tyre_attributes] + assert_not_deprecated_association(:tires) do + @car.tires_attributes = [@tire_attributes] end - assert_deprecated_association(:deprecated_tyres, context: context_for_method(:deprecated_tyres_attributes=)) do - @car.deprecated_tyres_attributes = [@tyre_attributes] + assert_deprecated_association(:deprecated_tires, context: context_for_method(:deprecated_tires_attributes=)) do + @car.deprecated_tires_attributes = [@tire_attributes] end - assert @tyre_attributes <= @car.deprecated_tyres[0].attributes + assert @tire_attributes <= @car.deprecated_tires[0].attributes end test "has_one" do diff --git a/activerecord/test/cases/relations_test.rb b/activerecord/test/cases/relations_test.rb index 470e20a707923..cbaf9a6ad7c38 100644 --- a/activerecord/test/cases/relations_test.rb +++ b/activerecord/test/cases/relations_test.rb @@ -20,7 +20,7 @@ require "models/bird" require "models/car" require "models/engine" -require "models/tyre" +require "models/tire" require "models/minivan" require "models/possession" require "models/reader" @@ -49,12 +49,12 @@ def test_do_not_double_quote_string_id_with_array def test_two_scopes_with_includes_should_not_drop_any_include # heat habtm cache - car = Car.incl_engines.incl_tyres.first - car.tyres.length + car = Car.incl_engines.incl_tires.first + car.tires.length car.engines.length - car = Car.incl_engines.incl_tyres.first - assert_no_queries { car.tyres.length } + car = Car.incl_engines.incl_tires.first + assert_no_queries { car.tires.length } assert_no_queries { car.engines.length } end @@ -2567,92 +2567,92 @@ class DeprecatedAssociationsRelationSimpleTest < ActiveRecord::TestCase end test "includes reports deprecated associations" do - assert_not_deprecated_association(:tyres) do - @model.includes(:tyres).to_a + assert_not_deprecated_association(:tires) do + @model.includes(:tires).to_a end - assert_deprecated_association(:deprecated_tyres, context: context_for_preload) do - @model.includes(:deprecated_tyres).to_a + assert_deprecated_association(:deprecated_tires, context: context_for_preload) do + @model.includes(:deprecated_tires).to_a end end test "eager_load reports deprecated associations" do - assert_not_deprecated_association(:tyres) do - @model.eager_load(:tyres).to_a + assert_not_deprecated_association(:tires) do + @model.eager_load(:tires).to_a end - assert_deprecated_association(:deprecated_tyres, context: context_for_join) do - @model.eager_load(:deprecated_tyres).to_a + assert_deprecated_association(:deprecated_tires, context: context_for_join) do + @model.eager_load(:deprecated_tires).to_a end end test "preload reports deprecated associations" do - assert_not_deprecated_association(:tyres) do - @model.preload(:tyres).to_a + assert_not_deprecated_association(:tires) do + @model.preload(:tires).to_a end - assert_deprecated_association(:deprecated_tyres, context: context_for_preload) do - @model.preload(:deprecated_tyres).to_a + assert_deprecated_association(:deprecated_tires, context: context_for_preload) do + @model.preload(:deprecated_tires).to_a end end test "extract_associated reports deprecated associations" do - assert_not_deprecated_association(:tyres) do - @model.extract_associated(:tyres).to_a + assert_not_deprecated_association(:tires) do + @model.extract_associated(:tires).to_a end - assert_deprecated_association(:deprecated_tyres, context: context_for_preload) do - @model.extract_associated(:deprecated_tyres).to_a + assert_deprecated_association(:deprecated_tires, context: context_for_preload) do + @model.extract_associated(:deprecated_tires).to_a end end test "joins reports deprecated associations" do - assert_not_deprecated_association(:tyres) do - @model.joins(:tyres).to_a + assert_not_deprecated_association(:tires) do + @model.joins(:tires).to_a end - assert_deprecated_association(:deprecated_tyres, context: context_for_join) do - @model.joins(:deprecated_tyres).to_a + assert_deprecated_association(:deprecated_tires, context: context_for_join) do + @model.joins(:deprecated_tires).to_a end end test "left_outer_joins reports deprecated associations" do - assert_not_deprecated_association(:tyres) do - @model.left_outer_joins(:tyres).to_a + assert_not_deprecated_association(:tires) do + @model.left_outer_joins(:tires).to_a end - assert_deprecated_association(:deprecated_tyres, context: context_for_join) do - @model.left_outer_joins(:deprecated_tyres).to_a + assert_deprecated_association(:deprecated_tires, context: context_for_join) do + @model.left_outer_joins(:deprecated_tires).to_a end end test "left_joins reports deprecated associations" do - assert_not_deprecated_association(:tyres) do - @model.left_joins(:tyres).to_a + assert_not_deprecated_association(:tires) do + @model.left_joins(:tires).to_a end - assert_deprecated_association(:deprecated_tyres, context: context_for_join) do - @model.left_joins(:deprecated_tyres).to_a + assert_deprecated_association(:deprecated_tires, context: context_for_join) do + @model.left_joins(:deprecated_tires).to_a end end test "associated reports deprecated associations" do - assert_not_deprecated_association(:tyres) do - @model.where.associated(:tyres).to_a + assert_not_deprecated_association(:tires) do + @model.where.associated(:tires).to_a end - assert_deprecated_association(:deprecated_tyres, context: context_for_join) do - @model.where.associated(:deprecated_tyres).to_a + assert_deprecated_association(:deprecated_tires, context: context_for_join) do + @model.where.associated(:deprecated_tires).to_a end end test "missing reports deprecated associations" do - assert_not_deprecated_association(:tyres) do - @model.where.missing(:tyres).to_a + assert_not_deprecated_association(:tires) do + @model.where.missing(:tires).to_a end - assert_deprecated_association(:deprecated_tyres, context: context_for_join) do - @model.where.missing(:deprecated_tyres).to_a + assert_deprecated_association(:deprecated_tires, context: context_for_join) do + @model.where.missing(:deprecated_tires).to_a end end end @@ -2667,7 +2667,7 @@ class DeprecatedAssociationsRelationComplexTest < ActiveRecord::TestCase end test "includes reports deprecated associations" do - assert_not_deprecated_association(:tyres) do + assert_not_deprecated_association(:tires) do @model.includes(:comments, author: :author_favorites).to_a end @@ -2681,7 +2681,7 @@ class DeprecatedAssociationsRelationComplexTest < ActiveRecord::TestCase end test "eager_load reports deprecated associations" do - assert_not_deprecated_association(:tyres) do + assert_not_deprecated_association(:tires) do @model.eager_load(:comments, author: :author_favorites).to_a end @@ -2695,7 +2695,7 @@ class DeprecatedAssociationsRelationComplexTest < ActiveRecord::TestCase end test "preload reports deprecated associations" do - assert_not_deprecated_association(:tyres) do + assert_not_deprecated_association(:tires) do @model.preload(:comments, author: :author_favorites).to_a end @@ -2709,7 +2709,7 @@ class DeprecatedAssociationsRelationComplexTest < ActiveRecord::TestCase end test "joins reports deprecated associations" do - assert_not_deprecated_association(:tyres) do + assert_not_deprecated_association(:tires) do @model.joins(:comments, author: :author_favorites).to_a end @@ -2723,7 +2723,7 @@ class DeprecatedAssociationsRelationComplexTest < ActiveRecord::TestCase end test "left_outer_joins reports deprecated associations" do - assert_not_deprecated_association(:tyres) do + assert_not_deprecated_association(:tires) do @model.left_outer_joins(:comments, author: :author_favorites).to_a end @@ -2737,7 +2737,7 @@ class DeprecatedAssociationsRelationComplexTest < ActiveRecord::TestCase end test "left_joins reports deprecated associations" do - assert_not_deprecated_association(:tyres) do + assert_not_deprecated_association(:tires) do @model.left_joins(:comments, author: :author_favorites).to_a end @@ -2751,7 +2751,7 @@ class DeprecatedAssociationsRelationComplexTest < ActiveRecord::TestCase end test "associated reports deprecated associations" do - assert_not_deprecated_association(:tyres) do + assert_not_deprecated_association(:tires) do @model.where.associated(:comments, :author_favorites).to_a end @@ -2767,7 +2767,7 @@ class DeprecatedAssociationsRelationComplexTest < ActiveRecord::TestCase end test "missing reports deprecated associations" do - assert_not_deprecated_association(:tyres) do + assert_not_deprecated_association(:tires) do @model.where.missing(:comments, :author_favorites).to_a end diff --git a/activerecord/test/cases/transaction_callbacks_test.rb b/activerecord/test/cases/transaction_callbacks_test.rb index 904b6dfba0a6a..4948acecc351a 100644 --- a/activerecord/test/cases/transaction_callbacks_test.rb +++ b/activerecord/test/cases/transaction_callbacks_test.rb @@ -647,7 +647,7 @@ def test_before_commit_update_in_same_transaction class CallbackOrderTest < ActiveRecord::TestCase self.use_transactional_tests = false - module Behaviour + module Behavior extend ActiveSupport::Concern included do @@ -679,14 +679,14 @@ def history run_after_transaction_callbacks_in_order_defined_was = ActiveRecord.run_after_transaction_callbacks_in_order_defined ActiveRecord.run_after_transaction_callbacks_in_order_defined = true class TopicWithCallbacksWithSpecificOrderWithSettingTrue < ActiveRecord::Base - include Behaviour + include Behavior end ActiveRecord.run_after_transaction_callbacks_in_order_defined = run_after_transaction_callbacks_in_order_defined_was run_after_transaction_callbacks_in_order_defined_was = ActiveRecord.run_after_transaction_callbacks_in_order_defined ActiveRecord.run_after_transaction_callbacks_in_order_defined = false class TopicWithCallbacksWithSpecificOrderWithSettingFalse < ActiveRecord::Base - include Behaviour + include Behavior end ActiveRecord.run_after_transaction_callbacks_in_order_defined = run_after_transaction_callbacks_in_order_defined_was diff --git a/activerecord/test/fixtures/cars.yml b/activerecord/test/fixtures/cars.yml index c81db8eddd142..5059b5d06940e 100644 --- a/activerecord/test/fixtures/cars.yml +++ b/activerecord/test/fixtures/cars.yml @@ -3,7 +3,7 @@ honda: name: honda engines_count: 0 bulbs_count: 0 - custom_tyres_count: 0 + custom_tires_count: 0 person_id: 1 zyke: @@ -11,5 +11,5 @@ zyke: name: zyke engines_count: 0 bulbs_count: 0 - custom_tyres_count: 0 + custom_tires_count: 0 person_id: 2 diff --git a/activerecord/test/models/car.rb b/activerecord/test/models/car.rb index 63856ae03249d..194a6829eb1d3 100644 --- a/activerecord/test/models/car.rb +++ b/activerecord/test/models/car.rb @@ -14,13 +14,13 @@ class Car < ActiveRecord::Base has_one :bulb - has_many :tyres, counter_cache: :custom_tyres_count + has_many :tires, counter_cache: :custom_tires_count has_many :engines, dependent: :destroy, inverse_of: :my_car has_many :wheels, as: :wheelable, dependent: :destroy has_many :price_estimates, as: :estimate_of - scope :incl_tyres, -> { includes(:tyres) } + scope :incl_tires, -> { includes(:tires) } scope :incl_engines, -> { includes(:engines) } scope :order_using_new_style, -> { order("name asc") } diff --git a/activerecord/test/models/dats.rb b/activerecord/test/models/dats.rb index aa21bdc907c42..f539ca2e60b3b 100644 --- a/activerecord/test/models/dats.rb +++ b/activerecord/test/models/dats.rb @@ -13,6 +13,6 @@ def self.table_name_prefix = "" require_relative "dats/category" require_relative "dats/comment" require_relative "dats/car" - require_relative "dats/tyre" + require_relative "dats/tire" require_relative "dats/bulb" end diff --git a/activerecord/test/models/dats/car.rb b/activerecord/test/models/dats/car.rb index 24edec9709c14..2b24660d4401d 100644 --- a/activerecord/test/models/dats/car.rb +++ b/activerecord/test/models/dats/car.rb @@ -4,10 +4,10 @@ class DATS::Car < ActiveRecord::Base self.lock_optimistically = false - has_many :tyres, class_name: "DATS::Tyre", dependent: :destroy - has_many :deprecated_tyres, class_name: "DATS::Tyre", dependent: :destroy, deprecated: true + has_many :tires, class_name: "DATS::Tire", dependent: :destroy + has_many :deprecated_tires, class_name: "DATS::Tire", dependent: :destroy, deprecated: true - accepts_nested_attributes_for :tyres, :deprecated_tyres + accepts_nested_attributes_for :tires, :deprecated_tires has_one :bulb, class_name: "DATS::Bulb", dependent: :destroy has_one :deprecated_bulb, class_name: "DATS::Bulb", dependent: :destroy, deprecated: true diff --git a/activerecord/test/models/dats/tyre.rb b/activerecord/test/models/dats/tire.rb similarity index 67% rename from activerecord/test/models/dats/tyre.rb rename to activerecord/test/models/dats/tire.rb index 76214b59f8d51..0ef8e8612253b 100644 --- a/activerecord/test/models/dats/tyre.rb +++ b/activerecord/test/models/dats/tire.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true # DATS = Deprecated Associations Test Suite. -class DATS::Tyre < ActiveRecord::Base +class DATS::Tire < ActiveRecord::Base end diff --git a/activerecord/test/models/tyre.rb b/activerecord/test/models/tire.rb similarity index 81% rename from activerecord/test/models/tyre.rb rename to activerecord/test/models/tire.rb index e207baa2805c8..ef4ebe8248ca3 100644 --- a/activerecord/test/models/tyre.rb +++ b/activerecord/test/models/tire.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true -class Tyre < ActiveRecord::Base - belongs_to :car, counter_cache: { active: true, column: :custom_tyres_count } +class Tire < ActiveRecord::Base + belongs_to :car, counter_cache: { active: true, column: :custom_tires_count } def self.custom_find(id) find(id) diff --git a/activerecord/test/schema/schema.rb b/activerecord/test/schema/schema.rb index f3bccd285625e..7b37f4542388c 100644 --- a/activerecord/test/schema/schema.rb +++ b/activerecord/test/schema/schema.rb @@ -196,7 +196,7 @@ t.integer :wheels_count, default: 0, null: false t.datetime :wheels_owned_at t.integer :bulbs_count - t.integer :custom_tyres_count + t.integer :custom_tires_count t.column :lock_version, :integer, null: false, default: 0 t.timestamps null: false end @@ -1272,7 +1272,7 @@ t.float :pitch end - create_table :tyres, force: true do |t| + create_table :tires, force: true do |t| t.integer :car_id end diff --git a/guides/assets/javascripts/guides.js b/guides/assets/javascripts/guides.js index a31cbaeb010d2..182ed58c7cbf6 100644 --- a/guides/assets/javascripts/guides.js +++ b/guides/assets/javascripts/guides.js @@ -36,10 +36,10 @@ var scrollBehavior = 'auto' document.addEventListener(event, function () { - // This smooth scrolling behaviour does not work in tandem with the + // This smooth scrolling behavior does not work in tandem with the // scrollIntoView function for some browser-os combinations. Therefore, if // JavaScript is enabled, and scrollIntoView may be called, this style is - // forced to not use smooth scrolling and the behaviour is added to the + // forced to not use smooth scrolling and the behavior is added to the // back-to-top element etc, unless reduced motion is preferred. document.body.parentElement.style.scrollBehavior = 'auto'; @@ -216,7 +216,7 @@ }) // Automatically browse when the version selector is changed. It is - // important that this behaviour is communicated to the user, for example + // important that this behavior is communicated to the user, for example // via an accessible label. var guidesVersion = document.querySelector("select.guides-version"); guidesVersion.addEventListener("change", function (e) { diff --git a/guides/assets/stylesrc/_main.scss b/guides/assets/stylesrc/_main.scss index 8f788ce20821e..bc26be5f85757 100644 --- a/guides/assets/stylesrc/_main.scss +++ b/guides/assets/stylesrc/_main.scss @@ -1,8 +1,8 @@ // Smooth scrolling (back-to-top etc.) @media (prefers-reduced-motion: no-preference) { - // This smooth scrolling behaviour does not work in tandem with scrollIntoView + // This smooth scrolling behavior does not work in tandem with scrollIntoView // for some browser-os combinations. Therefore, if JavaScript is enabled, this - // style is removed and the behaviour is added to the back-to-top element etc. + // style is removed and the behavior is added to the back-to-top element etc. html { scroll-behavior: smooth; } diff --git a/guides/source/configuring.md b/guides/source/configuring.md index 6e0de6b0c5e73..e05ae12fe6c9f 100644 --- a/guides/source/configuring.md +++ b/guides/source/configuring.md @@ -3675,7 +3675,7 @@ development: use_metadata_table: false ``` -#### Configuring Retry Behaviour +#### Configuring Retry Behavior By default, Rails will automatically reconnect to the database server and retry certain queries if something goes wrong. Only safely retryable (idempotent) queries will be retried. The number diff --git a/guides/source/generators.md b/guides/source/generators.md index 00e7dde957f08..4d66d11ebadc6 100644 --- a/guides/source/generators.md +++ b/guides/source/generators.md @@ -797,7 +797,7 @@ Testing Generators ------------------ Rails provides testing helper methods via -[`Rails::Generators::Testing::Behaviour`][], such as: +[`Rails::Generators::Testing::Behavior`][], such as: * [`run_generator`][] @@ -824,7 +824,7 @@ In addition to those, Rails also provides additional assertions via [`rails_command`]: https://api.rubyonrails.org/classes/Rails/Generators/Actions.html#method-i-rails_command [`rake`]: https://api.rubyonrails.org/classes/Rails/Generators/Actions.html#method-i-rake [`route`]: https://api.rubyonrails.org/classes/Rails/Generators/Actions.html#method-i-route -[`Rails::Generators::Testing::Behaviour`]: https://api.rubyonrails.org/classes/Rails/Generators/Testing/Behavior.html +[`Rails::Generators::Testing::Behavior`]: https://api.rubyonrails.org/classes/Rails/Generators/Testing/Behavior.html [`run_generator`]: https://api.rubyonrails.org/classes/Rails/Generators/Testing/Behavior.html#method-i-run_generator [`Rails::Generators::Testing::Assertions`]: https://api.rubyonrails.org/classes/Rails/Generators/Testing/Assertions.html [`add_source`]: https://api.rubyonrails.org/classes/Rails/Generators/Actions.html#method-i-add_source diff --git a/guides/source/upgrading_ruby_on_rails.md b/guides/source/upgrading_ruby_on_rails.md index e4f16282f159b..1564956f63b06 100644 --- a/guides/source/upgrading_ruby_on_rails.md +++ b/guides/source/upgrading_ruby_on_rails.md @@ -331,7 +331,7 @@ I18n.t("missing.key") # didn't raise in 7.0, doesn't raise in 7.1 I18n.t("missing.key") # didn't raise in 7.0, doesn't raise in 7.1 ``` -Alternatively, you can customise the `I18n.exception_handler`. +Alternatively, you can customize 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 From 2dcdb669e75fec37861efcc7284058a444361219 Mon Sep 17 00:00:00 2001 From: fatkodima Date: Wed, 10 Sep 2025 12:28:54 +0300 Subject: [PATCH 0598/1075] Revert "Preserve order within individual batches for batch iteration" This reverts commit 80fac8c4b279d87dab6b0b30dd4001dda6dbe382. --- .../lib/active_record/relation/batches.rb | 6 +-- activerecord/test/cases/batches_test.rb | 37 ++++--------------- 2 files changed, 10 insertions(+), 33 deletions(-) diff --git a/activerecord/lib/active_record/relation/batches.rb b/activerecord/lib/active_record/relation/batches.rb index fb4beda87569e..2562ce1aaac13 100644 --- a/activerecord/lib/active_record/relation/batches.rb +++ b/activerecord/lib/active_record/relation/batches.rb @@ -437,7 +437,7 @@ def batch_on_unloaded_relation(relation:, start:, finish:, load:, cursor:, order values = records.pluck(*cursor) values_size = values.size values_last = values.last - yielded_relation = where(cursor => values).order(batch_orders.to_h) + yielded_relation = where(cursor => values) yielded_relation.load_records(records) elsif (empty_scope && use_ranges != false) || use_ranges # Efficiently peak at the last value for the next batch using offset and limit. @@ -455,14 +455,14 @@ def batch_on_unloaded_relation(relation:, start:, finish:, load:, cursor:, order # Finally, build the yielded relation if at least one value found. if values_last yielded_relation = apply_finish_limit(batch_relation, cursor, values_last, batch_orders) - yielded_relation = yielded_relation.except(:limit).reorder(batch_orders.to_h) + yielded_relation = yielded_relation.except(:limit, :order) yielded_relation.skip_query_cache!(false) end else values = batch_relation.pluck(*cursor) values_size = values.size values_last = values.last - yielded_relation = where(cursor => values).order(batch_orders.to_h) + yielded_relation = where(cursor => values) end break if values_size == 0 diff --git a/activerecord/test/cases/batches_test.rb b/activerecord/test/cases/batches_test.rb index 8e41033495c2e..43816b02580c5 100644 --- a/activerecord/test/cases/batches_test.rb +++ b/activerecord/test/cases/batches_test.rb @@ -455,36 +455,6 @@ def test_in_batches_should_be_loaded end end - def test_in_loaded_batches_preserves_order_within_batches - expected_posts = Post.order(id: :desc).to_a - posts = [] - - Post.in_batches(of: 2, load: true, order: :desc) do |relation| - posts.concat(relation.where("1 = 1")) - end - assert_equal expected_posts, posts - end - - def test_in_range_batches_preserves_order_within_batches - expected_posts = Post.order(id: :desc).to_a - posts = [] - - Post.in_batches(of: 2, order: :desc, use_ranges: true) do |relation| - posts.concat(relation) - end - assert_equal expected_posts, posts - end - - def test_in_scoped_batches_preserves_order_within_batches - expected_posts = Post.order(id: :desc).to_a - posts = [] - - Post.where("id > 0").in_batches(of: 2, order: :desc) do |relation| - posts.concat(relation) - end - assert_equal expected_posts, posts - end - def test_in_batches_if_not_loaded_executes_more_queries assert_queries_count(@total + 2) do Post.in_batches(of: 1, load: false) do |relation| @@ -692,6 +662,13 @@ def test_in_batches_executes_range_queries_when_constrained_and_opted_in_into_ra end end + def test_in_batches_no_subqueries_for_whole_tables_batching + quoted_posts_id = Regexp.escape(quote_table_name("posts.id")) + assert_queries_match(/DELETE FROM #{Regexp.escape(quote_table_name("posts"))} WHERE #{quoted_posts_id} > .+ AND #{quoted_posts_id} <=/i) do + Post.in_batches(of: 2).delete_all + end + end + def test_in_batches_shouldnt_execute_query_unless_needed assert_queries_count(3) do Post.in_batches(of: @total) { |relation| assert_kind_of ActiveRecord::Relation, relation } From 69919bcb3bdf0c4ec58301dd598feb4f994a2160 Mon Sep 17 00:00:00 2001 From: Carl Eqladios Date: Wed, 10 Sep 2025 19:41:58 +0300 Subject: [PATCH 0599/1075] Do not add seed step to config/ci if active record is skipped --- .../rails/app/templates/config/ci.rb.tt | 2 ++ railties/test/generators/app_generator_test.rb | 17 +++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/railties/lib/rails/generators/rails/app/templates/config/ci.rb.tt b/railties/lib/rails/generators/rails/app/templates/config/ci.rb.tt index a825d0152416c..fcc3ec877001e 100644 --- a/railties/lib/rails/generators/rails/app/templates/config/ci.rb.tt +++ b/railties/lib/rails/generators/rails/app/templates/config/ci.rb.tt @@ -22,7 +22,9 @@ CI.run do step "Tests: Rails", "bin/rails test" step "Tests: System", "bin/rails test:system" <% end -%> +<% unless options.skip_active_record? -%> step "Tests: Seeds", "env RAILS_ENV=test bin/rails db:seed:replant" +<% end -%> # Optional: set a green GitHub commit status to unblock PR merge. # Requires the `gh` CLI and `gh extension install basecamp/gh-signoff`. diff --git a/railties/test/generators/app_generator_test.rb b/railties/test/generators/app_generator_test.rb index eefbfab1a2341..dd0d37d317e4d 100644 --- a/railties/test/generators/app_generator_test.rb +++ b/railties/test/generators/app_generator_test.rb @@ -669,6 +669,23 @@ def test_inclusion_of_ci_files end end + def test_config_ci_includes_seed_step_by_default + run_generator [destination_root] + + assert_file "config/ci.rb" do |content| + assert_match(/step "Tests: Seeds", "env RAILS_ENV=test bin\/rails db:seed:replant"/, content) + end + end + + def test_config_ci_does_not_include_seed_step_when_skip_active_record_is_given + run_generator [destination_root, "--skip-active-record"] + + assert_file "config/ci.rb" do |content| + assert_no_match(/step "Tests: Seeds"/, content) + assert_no_match(/bin\/rails db:seed:replant/, content) + end + end + def test_ci_workflow_includes_db_test_prepare_by_default run_generator assert_file ".github/workflows/ci.yml" do |content| From 36785ad2bdd94c617b4e00633ed199c216f40804 Mon Sep 17 00:00:00 2001 From: Joshua Young Date: Tue, 9 Sep 2025 13:00:34 +0200 Subject: [PATCH 0600/1075] Optimise `ActionDispatch::Http::URL.build_host_url` when protocol in host Co-authored-by: Hartley McGuire --- actionpack/CHANGELOG.md | 9 +++++++++ actionpack/lib/action_dispatch/http/url.rb | 22 +++++++++++----------- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/actionpack/CHANGELOG.md b/actionpack/CHANGELOG.md index 1debed627325f..890ca36d5541c 100644 --- a/actionpack/CHANGELOG.md +++ b/actionpack/CHANGELOG.md @@ -1,3 +1,12 @@ +* Optimize `ActionDispatch::Http::URL.build_host_url` when protocol is included in host. + + When using URL helpers with a host that includes the protocol (e.g., `{ host: "https://example.com" }`), + skip unnecessary protocol normalization and string duplication since the extracted protocol is already + in the correct format. This eliminates 2 string allocations per URL generation and provides a ~10% + performance improvement for this case. + + *Joshua Young*, *Hartley McGuire* + * Allow `action_controller.logger` to be disabled by setting it to `nil` or `false` instead of always defaulting to `Rails.logger`. *Roberto Miranda* diff --git a/actionpack/lib/action_dispatch/http/url.rb b/actionpack/lib/action_dispatch/http/url.rb index 7570a6a9afaa6..e7fecc7b756a7 100644 --- a/actionpack/lib/action_dispatch/http/url.rb +++ b/actionpack/lib/action_dispatch/http/url.rb @@ -202,24 +202,24 @@ def extract_subdomains_from(host, tld_length) def build_host_url(host, port, protocol, options, path) if match = host.match(HOST_REGEXP) - protocol ||= match[1] unless protocol == false - host = match[2] - port = match[3] unless options.key? :port + protocol_from_host = match[1] if protocol.nil? + host = match[2] + port = match[3] unless options.key? :port end - protocol = normalize_protocol protocol + protocol = protocol_from_host || normalize_protocol(protocol).dup host = normalize_host(host, options) + port = normalize_port(port, protocol) - result = protocol.dup + result = protocol if options[:user] && options[:password] result << "#{Rack::Utils.escape(options[:user])}:#{Rack::Utils.escape(options[:password])}@" end result << host - normalize_port(port, protocol) { |normalized_port| - result << ":#{normalized_port}" - } + + result << ":" << port.to_s if port result.concat path end @@ -265,11 +265,11 @@ def normalize_port(port, protocol) return unless port case protocol - when "//" then yield port + when "//" then port when "https://" - yield port unless port.to_i == 443 + port unless port.to_i == 443 else - yield port unless port.to_i == 80 + port unless port.to_i == 80 end end end From 14940f28da495fe39ad86aa001d351839d9f6c5c Mon Sep 17 00:00:00 2001 From: Petrik Date: Wed, 27 Aug 2025 08:39:25 +0200 Subject: [PATCH 0601/1075] Show engine routes in `/rails/info/routes` as well. `_routes.routes` does not include engine routes, so engine routes don't show up in `/rails/info/routes`. By using `Rails.application.routes.routes`, similar to `Rails::Command::RoutesCommand`, the routes do show up. Update railties/CHANGELOG.md Co-authored-by: Hartley McGuire --- railties/CHANGELOG.md | 4 ++++ railties/lib/rails/info_controller.rb | 4 ++-- railties/test/rails_info_controller_test.rb | 25 ++++++++++++++++++++- 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/railties/CHANGELOG.md b/railties/CHANGELOG.md index 530b0782f195c..dec758ce56f00 100644 --- a/railties/CHANGELOG.md +++ b/railties/CHANGELOG.md @@ -1,3 +1,7 @@ +* Show engine routes in `/rails/info/routes` as well. + + *Petrik de Heus* + * Exclude `asset_path` configuration from Kamal `deploy.yml` for API applications. API applications don't serve assets, so the `asset_path` configuration in `deploy.yml` diff --git a/railties/lib/rails/info_controller.rb b/railties/lib/rails/info_controller.rb index e6fd6a75e96fd..a6151a460a07e 100644 --- a/railties/lib/rails/info_controller.rb +++ b/railties/lib/rails/info_controller.rb @@ -27,7 +27,7 @@ def routes fuzzy: matching_routes(query: query, exact_match: false) } else - @routes_inspector = ActionDispatch::Routing::RoutesInspector.new(_routes.routes) + @routes_inspector = ActionDispatch::Routing::RoutesInspector.new(Rails.application.routes.routes) @page_title = "Routes" end end @@ -46,7 +46,7 @@ def matching_routes(query:, exact_match:) normalized_path = ("/" + query).squeeze("/") query_without_url_or_path_suffix = query.gsub(/(\w)(_path$)/, '\1').gsub(/(\w)(_url$)/, '\1') - _routes.routes.filter_map do |route| + Rails.application.routes.routes.filter_map do |route| route_wrapper = ActionDispatch::Routing::RouteWrapper.new(route) if exact_match diff --git a/railties/test/rails_info_controller_test.rb b/railties/test/rails_info_controller_test.rb index 6755cbdd77578..0fb8bac37cc16 100644 --- a/railties/test/rails_info_controller_test.rb +++ b/railties/test/rails_info_controller_test.rb @@ -9,6 +9,13 @@ class InfoControllerTest < ActionController::TestCase def setup ActionController::Base.include ActionController::Testing + engine = Class.new(::Rails::Engine) do |blorgh| + railtie_name "blorgh" + blorgh.routes.draw do + resources :posts + end + end + Rails.application.routes.draw do namespace :test do get :nested_route, to: "test#show" @@ -18,6 +25,7 @@ def setup get "/rails/info/notes" => "rails/info#notes" post "/rails/:test/properties" => "rails/info#properties" put "/rails/:test/named_properties" => "rails/info#properties", as: "named_rails_info_properties" + mount engine, at: "/blog" end @routes = Rails.application.routes @@ -86,7 +94,18 @@ def fuzzy_results assert_select("table tr") do assert_select("td", text: "test_nested_route_path") assert_select("td", text: "test/test#show") - assert_select("td", text: "#{__FILE__}:79") + assert_select("td", text: "#{__FILE__}:87") + end + end + + test "info controller routes shows engine routes" do + get :routes + + assert_select("table tr") do + assert_select("td", text: "blorgh_path") + assert_select("td", text: "/blog") + assert_select("td", text: "posts_path") + assert_select("td", text: "posts#index") end end @@ -108,6 +127,10 @@ def fuzzy_results get :routes, params: { query: "rails_info_properties_url" } assert exact_results.size == 1, "should match complete route urls" assert exact_results.include? "/rails/info/properties(.:format)" + + get :routes, params: { query: "blog" } + assert exact_results.size == 1, "should match complete engine route paths" + assert exact_results.include? "/blog" end test "info controller search returns exact matches for route paths" do From c93d1b09fcc013033af506b10fd60829267be85c Mon Sep 17 00:00:00 2001 From: fatkodima Date: Thu, 11 Sep 2025 11:17:14 +0300 Subject: [PATCH 0602/1075] Optimize getting primary keys for PostgreSQL Co-authored-by: G-Ork --- .../postgresql/schema_statements.rb | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) 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 8b5ed125e3228..d5562e8b4426d 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/schema_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/schema_statements.rb @@ -435,16 +435,13 @@ def pk_and_sequence_for(table) # :nodoc: def primary_keys(table_name) # :nodoc: query_values(<<~SQL, "SCHEMA") SELECT a.attname - FROM ( - SELECT indrelid, indkey, generate_subscripts(indkey, 1) idx - FROM pg_index - WHERE indrelid = #{quote(quote_table_name(table_name))}::regclass - AND indisprimary - ) i - JOIN pg_attribute a - ON a.attrelid = i.indrelid - AND a.attnum = i.indkey[i.idx] - ORDER BY i.idx + FROM pg_index i + JOIN pg_attribute a + ON a.attrelid = i.indrelid + AND a.attnum = ANY(i.indkey) + WHERE i.indrelid = #{quote(quote_table_name(table_name))}::regclass + AND i.indisprimary + ORDER BY array_position(i.indkey, a.attnum) SQL end From 954622118860752af1502d12fd0d0c5236db338b Mon Sep 17 00:00:00 2001 From: viralpraxis Date: Thu, 11 Sep 2025 17:55:58 +0400 Subject: [PATCH 0603/1075] Fix leftover references to `ActiveStorage::RepresentationsController` in docs `RepresentationsController` was split [1] into `ProxyController` and `RedirectController`. [1] https://github.com/rails/rails/commit/dfb5a82b259e134eac89784ac4ace0c44d1b4aee co-authored-by: Svyatoslav Kryukov --- .../app/models/active_storage/blob/representable.rb | 4 ++-- activestorage/app/models/active_storage/variant.rb | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/activestorage/app/models/active_storage/blob/representable.rb b/activestorage/app/models/active_storage/blob/representable.rb index e929074af5fc6..644a1dd4c4f25 100644 --- a/activestorage/app/models/active_storage/blob/representable.rb +++ b/activestorage/app/models/active_storage/blob/representable.rb @@ -25,8 +25,8 @@ module ActiveStorage::Blob::Representable # # <%= image_tag Current.user.avatar.variant(resize_to_limit: [100, 100]) %> # - # This will create a URL for that specific blob with that specific variant, which the ActiveStorage::RepresentationsController - # can then produce on-demand. + # This will create a URL for that specific blob with that specific variant, which the ActiveStorage::Representations::ProxyController + # or ActiveStorage::Representations::RedirectController can then produce on-demand. # # Raises ActiveStorage::InvariableError if the variant processor cannot # transform the blob. To determine whether a blob is variable, call diff --git a/activestorage/app/models/active_storage/variant.rb b/activestorage/app/models/active_storage/variant.rb index 74c3084a8e96d..3a063ca0cf29c 100644 --- a/activestorage/app/models/active_storage/variant.rb +++ b/activestorage/app/models/active_storage/variant.rb @@ -22,15 +22,15 @@ # Note that to create a variant it's necessary to download the entire blob file from the service. Because of this process, # you also want to be considerate about when the variant is actually processed. You shouldn't be processing variants inline # in a template, for example. Delay the processing to an on-demand controller, like the one provided in -# ActiveStorage::RepresentationsController. +# ActiveStorage::Representations::ProxyController and ActiveStorage::Representations::RedirectController. # # To refer to such a delayed on-demand variant, simply link to the variant through the resolved route provided # by Active Storage like so: # # <%= image_tag Current.user.avatar.variant(resize_to_limit: [100, 100]) %> # -# This will create a URL for that specific blob with that specific variant, which the ActiveStorage::RepresentationsController -# can then produce on-demand. +# This will create a URL for that specific blob with that specific variant, which the ActiveStorage::Representations::ProxyController +# or ActiveStorage::Representations::RedirectController can then produce on-demand. # # When you do want to actually produce the variant needed, call +processed+. This will check that the variant # has already been processed and uploaded to the service, and, if so, just return that. Otherwise it will perform @@ -77,8 +77,8 @@ def key # Returns the URL of the blob variant on the service. See ActiveStorage::Blob#url for details. # # Use url_for(variant) (or the implied form, like link_to variant or redirect_to variant) to get the stable URL - # for a variant that points to the ActiveStorage::RepresentationsController, which in turn will use this +service_call+ method - # for its redirection. + # for a variant that points to the ActiveStorage::Representations::ProxyController or ActiveStorage::Representations::RedirectController, + # which in turn will use this +service_call+ method for its redirection. def url(expires_in: ActiveStorage.service_urls_expire_in, disposition: :inline) service.url key, expires_in: expires_in, disposition: disposition, filename: filename, content_type: content_type end From c9926bc9c48cc0614c88ee8dcc0e51bcc71a72df Mon Sep 17 00:00:00 2001 From: chaadow Date: Thu, 11 Sep 2025 20:34:22 +0100 Subject: [PATCH 0604/1075] Do not inline process mailers when calling deliver_all_later --- actionmailer/lib/action_mailer/message_delivery.rb | 2 +- actionmailer/test/message_delivery_test.rb | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/actionmailer/lib/action_mailer/message_delivery.rb b/actionmailer/lib/action_mailer/message_delivery.rb index 1bde0d512db45..4020807182086 100644 --- a/actionmailer/lib/action_mailer/message_delivery.rb +++ b/actionmailer/lib/action_mailer/message_delivery.rb @@ -20,7 +20,7 @@ def deliver_all_later!(*deliveries, **options) private def _deliver_all_later(delivery_method, *deliveries, **options) - deliveries.flatten! + deliveries = deliveries.first if deliveries.first.is_a?(Array) jobs = deliveries.map do |delivery| mailer_class = delivery.mailer_class diff --git a/actionmailer/test/message_delivery_test.rb b/actionmailer/test/message_delivery_test.rb index 82b59a885592d..cc36df31a77f3 100644 --- a/actionmailer/test/message_delivery_test.rb +++ b/actionmailer/test/message_delivery_test.rb @@ -160,6 +160,16 @@ class DummyJob < ActionMailer::MailDeliveryJob; end end end + test "deliver_all_later does not inline process the mailers" do + mail1 = DelayedMailer.test_message(1) + mail2 = DelayedMailer.test_message(2) + + ActionMailer.deliver_all_later(mail1, mail2) + + assert_not mail1.processed? + assert_not mail2.processed? + end + test "deliver_all_later enqueues multiple deliveries with correct jobs" do old_delivery_job = BaseMailer.delivery_job BaseMailer.delivery_job = DummyJob From 1850a9fee7cf2d4559df582eeb064ca7cb8f6d6a Mon Sep 17 00:00:00 2001 From: Adrianna Chang Date: Thu, 11 Sep 2025 12:45:20 -0400 Subject: [PATCH 0605/1075] Emit structured debug events in development only --- railties/lib/rails/application/bootstrap.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/railties/lib/rails/application/bootstrap.rb b/railties/lib/rails/application/bootstrap.rb index 3be48e4a1a7ce..dd15e2c097612 100644 --- a/railties/lib/rails/application/bootstrap.rb +++ b/railties/lib/rails/application/bootstrap.rb @@ -73,7 +73,7 @@ module Bootstrap initializer :initialize_event_reporter, group: :all do Rails.event.raise_on_error = config.consider_all_requests_local - Rails.event.debug_mode = config.consider_all_requests_local + Rails.event.debug_mode = Rails.env.development? end # Initialize cache early in the stack so railties can make use of it. From 179726184f219e99b32fbe0fd67d16a2d5bbdfe7 Mon Sep 17 00:00:00 2001 From: chaadow Date: Thu, 11 Sep 2025 21:32:10 +0100 Subject: [PATCH 0606/1075] Add missing test when an array is passed to --- actionmailer/test/message_delivery_test.rb | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/actionmailer/test/message_delivery_test.rb b/actionmailer/test/message_delivery_test.rb index cc36df31a77f3..b011bba1a8670 100644 --- a/actionmailer/test/message_delivery_test.rb +++ b/actionmailer/test/message_delivery_test.rb @@ -168,6 +168,14 @@ class DummyJob < ActionMailer::MailDeliveryJob; end assert_not mail1.processed? assert_not mail2.processed? + + mail1 = DelayedMailer.test_message(1) + mail2 = DelayedMailer.test_message(2) + + ActionMailer.deliver_all_later([mail1, mail2]) + + assert_not mail1.processed? + assert_not mail2.processed? end test "deliver_all_later enqueues multiple deliveries with correct jobs" do From 9d3c6dd91365d1ad687edc5cb57ea6b5c615edcd Mon Sep 17 00:00:00 2001 From: Nick Pezza Date: Thu, 11 Sep 2025 15:43:14 -0400 Subject: [PATCH 0607/1075] If a serialized job is decoded when ActiveSupport.parse_json_times is true, use the Time object --- activejob/lib/active_job/core.rb | 12 ++++- .../test/cases/job_serialization_test.rb | 49 +++++++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/activejob/lib/active_job/core.rb b/activejob/lib/active_job/core.rb index 2c9544e3031f9..541f490137714 100644 --- a/activejob/lib/active_job/core.rb +++ b/activejob/lib/active_job/core.rb @@ -167,8 +167,8 @@ 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 = 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"] + self.enqueued_at = deserialize_time(job_data["enqueued_at"]) + self.scheduled_at = deserialize_time(job_data["scheduled_at"]) end # Configures the job with the given options. @@ -208,5 +208,13 @@ def deserialize_arguments(serialized_args) def arguments_serialized? @serialized_arguments end + + def deserialize_time(time) + if time.is_a?(Time) + time + elsif time + Time.iso8601(time) + end + end end end diff --git a/activejob/test/cases/job_serialization_test.rb b/activejob/test/cases/job_serialization_test.rb index 769b905ca8775..597aa1834417a 100644 --- a/activejob/test/cases/job_serialization_test.rb +++ b/activejob/test/cases/job_serialization_test.rb @@ -5,6 +5,7 @@ require "jobs/hello_job" require "models/person" require "json" +require "active_support/json" class JobSerializationTest < ActiveSupport::TestCase setup do @@ -40,6 +41,45 @@ class JobSerializationTest < ActiveSupport::TestCase end end + test "deserializes enqueued_at when ActiveSupport.parse_json_times is true" do + freeze_time + + Time.use_zone "US/Eastern" do + with_parse_json_times(true) do + current_time = Time.now + + job = HelloJob.new + serialized_job = job.serialize + payload = ActiveSupport::JSON.decode(serialized_job.to_json) + + new_job = HelloJob.new + new_job.deserialize(payload) + + assert_equal current_time, new_job.enqueued_at + end + end + end + + test "deserializes scheduled_at when ActiveSupport.parse_json_times is true" do + freeze_time + + Time.use_zone "US/Eastern" do + with_parse_json_times(true) do + current_time = Time.now + + job = HelloJob.new + job.scheduled_at = current_time + serialized_job = job.serialize + payload = ActiveSupport::JSON.decode(serialized_job.to_json) + + new_job = HelloJob.new + new_job.deserialize(payload) + + assert_equal current_time, new_job.scheduled_at + end + end + end + test "serialize and deserialize are symmetric" do # Ensure `enqueued_at` does not change between serializations freeze_time @@ -122,4 +162,13 @@ class JobSerializationTest < ActiveSupport::TestCase assert_equal job.serialize, deserialized_job.serialize end + + private + def with_parse_json_times(value) + old_value = ActiveSupport.parse_json_times + ActiveSupport.parse_json_times = value + yield + ensure + ActiveSupport.parse_json_times = old_value + end end From 367445d037b8f0d0baea684d14c0343ee29e8da1 Mon Sep 17 00:00:00 2001 From: zzak Date: Wed, 29 Jan 2025 22:35:29 +0100 Subject: [PATCH 0608/1075] Restore Active Storage config to disable variants and analyzers Fixes a couple of mistakes made in e978ef0. * Allow setting `config.active_storage.analyzers = []` * Allow disabling `config.active_storage.variant_processor` to remove warning * Ensure original default `ActiveStorage.analyzers` array is not broken (all available analyzers) * Ensure original default `ActiveStorage.variant_processor` is not broken (`:mini_magick` until `load_defaults(7.0)`) This change restores the configuration of analyzers back to 8.0, instead of trying to gracefully handle loading those constants if the associated gem is missing. Due to that config being set at require time, this happens prior to initialization and we don't have access to a logger or deprecator to warn the user. Since `image_processing` gem transitively depends on `ruby-vips` and `mini_magick`, you only need to install that to remove the warning. However, if you're not using variant transformers, but still using the default analyzers you can remove the warning by including those gems or setting the analyzers config to an empty array. We determine the variant transformer based on `config.active_storage.variant_processor` value, or nothing if you passed an invalid value (like `mmmmmini_magick`). Passing an invalid value, should have the same existing behavior and not impact a working application but in the future we'd like to deprecate it. Co-authored-by: Alexandre Ruban --- activestorage/CHANGELOG.md | 22 ++++++ activestorage/lib/active_storage.rb | 1 + .../analyzer/image_analyzer/vips.rb | 8 ++ activestorage/lib/active_storage/engine.rb | 22 ++---- .../transformers/null_transformer.rb | 12 +++ activestorage/test/jobs/transform_job_test.rb | 18 +++++ guides/source/configuring.md | 15 +++- .../analyzers_integration_test.rb | 60 +++++++++++++++ .../active_storage/engine_integration_test.rb | 76 +++++++++++++++++++ .../test/application/configuration_test.rb | 33 +++++++- 10 files changed, 250 insertions(+), 17 deletions(-) create mode 100644 activestorage/lib/active_storage/transformers/null_transformer.rb create mode 100644 railties/test/application/active_storage/analyzers_integration_test.rb create mode 100644 railties/test/application/active_storage/engine_integration_test.rb diff --git a/activestorage/CHANGELOG.md b/activestorage/CHANGELOG.md index 9b00f1d586713..78574423646f7 100644 --- a/activestorage/CHANGELOG.md +++ b/activestorage/CHANGELOG.md @@ -1,3 +1,25 @@ +* Allow analyzers and variant transformer to be fully configurable + + ```ruby + # ActiveStorage.analyzers can be set to an empty array: + config.active_storage.analyzers = [] + # => ActiveStorage.analyzers = [] + + # or use custom analyzer: + config.active_storage.analyzers = [ CustomAnalyzer ] + # => ActiveStorage.analyzers = [ CustomAnalyzer ] + ``` + + If no configuration is provided, it will use the default analyzers. + + You can also disable variant processor to remove warnings on startup about missing gems. + + ```ruby + config.active_storage.variant_processor = :disabled + ``` + + *zzak*, *Alexandre Ruban* + ## Rails 8.1.0.beta1 (September 04, 2025) ## * Remove deprecated `:azure` storage service. diff --git a/activestorage/lib/active_storage.rb b/activestorage/lib/active_storage.rb index 8e9ea3c02558f..ea8ea37ec7cdf 100644 --- a/activestorage/lib/active_storage.rb +++ b/activestorage/lib/active_storage.rb @@ -377,6 +377,7 @@ module Transformers extend ActiveSupport::Autoload autoload :Transformer + autoload :NullTransformer autoload :ImageProcessingTransformer autoload :Vips autoload :ImageMagick diff --git a/activestorage/lib/active_storage/analyzer/image_analyzer/vips.rb b/activestorage/lib/active_storage/analyzer/image_analyzer/vips.rb index 730bea19cce6a..68a9f87f7d7ad 100644 --- a/activestorage/lib/active_storage/analyzer/image_analyzer/vips.rb +++ b/activestorage/lib/active_storage/analyzer/image_analyzer/vips.rb @@ -1,5 +1,13 @@ # frozen_string_literal: true +begin + require "nokogiri" +rescue LoadError + # Ensure nokogiri is loaded before vips, which also depends on libxml2. + # See Nokogiri RFC: Stop exporting symbols: + # https://github.com/sparklemotion/nokogiri/discussions/2746 +end + begin gem "ruby-vips" require "ruby-vips" diff --git a/activestorage/lib/active_storage/engine.rb b/activestorage/lib/active_storage/engine.rb index 81cb8f23f7264..d74ae705563c8 100644 --- a/activestorage/lib/active_storage/engine.rb +++ b/activestorage/lib/active_storage/engine.rb @@ -25,7 +25,7 @@ class Engine < Rails::Engine # :nodoc: config.active_storage = ActiveSupport::OrderedOptions.new config.active_storage.previewers = [ ActiveStorage::Previewer::PopplerPDFPreviewer, ActiveStorage::Previewer::MuPDFPreviewer, ActiveStorage::Previewer::VideoPreviewer ] - config.active_storage.analyzers = [ ActiveStorage::Analyzer::VideoAnalyzer, ActiveStorage::Analyzer::AudioAnalyzer ] + config.active_storage.analyzers = [ ActiveStorage::Analyzer::ImageAnalyzer::Vips, ActiveStorage::Analyzer::ImageAnalyzer::ImageMagick, ActiveStorage::Analyzer::VideoAnalyzer, ActiveStorage::Analyzer::AudioAnalyzer ] config.active_storage.paths = ActiveSupport::OrderedOptions.new config.active_storage.queues = ActiveSupport::InheritableOptions.new config.active_storage.precompile_assets = true @@ -88,26 +88,20 @@ class Engine < Rails::Engine # :nodoc: config.after_initialize do |app| ActiveStorage.logger = app.config.active_storage.logger || Rails.logger - ActiveStorage.variant_processor = app.config.active_storage.variant_processor + ActiveStorage.variant_processor = app.config.active_storage.variant_processor || :mini_magick ActiveStorage.previewers = app.config.active_storage.previewers || [] + ActiveStorage.analyzers = app.config.active_storage.analyzers || [] begin - analyzer, transformer = + ActiveStorage.variant_transformer = case ActiveStorage.variant_processor + when :disabled + ActiveStorage::Transformers::NullTransformer when :vips - [ - ActiveStorage::Analyzer::ImageAnalyzer::Vips, - ActiveStorage::Transformers::Vips - ] + ActiveStorage::Transformers::Vips when :mini_magick - [ - ActiveStorage::Analyzer::ImageAnalyzer::ImageMagick, - ActiveStorage::Transformers::ImageMagick - ] + ActiveStorage::Transformers::ImageMagick end - - ActiveStorage.analyzers = [analyzer].compact.concat(app.config.active_storage.analyzers || []) - ActiveStorage.variant_transformer = transformer rescue LoadError => error case error.message when /libvips/ diff --git a/activestorage/lib/active_storage/transformers/null_transformer.rb b/activestorage/lib/active_storage/transformers/null_transformer.rb new file mode 100644 index 0000000000000..6879fd5641604 --- /dev/null +++ b/activestorage/lib/active_storage/transformers/null_transformer.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module ActiveStorage + module Transformers + class NullTransformer < Transformer # :nodoc: + private + def process(file, format:) + file + end + end + end +end diff --git a/activestorage/test/jobs/transform_job_test.rb b/activestorage/test/jobs/transform_job_test.rb index 174d358169380..dc509e802102c 100644 --- a/activestorage/test/jobs/transform_job_test.rb +++ b/activestorage/test/jobs/transform_job_test.rb @@ -55,4 +55,22 @@ class ActiveStorage::TransformJobTest < ActiveJob::TestCase end end end + + test "null transformer returns original file" do + @was_transformer = ActiveStorage.variant_transformer + ActiveStorage.variant_transformer = ActiveStorage::Transformers::NullTransformer + + transformations = { resize_to_limit: [100, 100] } + assert_changes -> { @blob.variant(transformations).send(:processed?) }, from: false, to: true do + perform_enqueued_jobs do + ActiveStorage::TransformJob.perform_later @blob, transformations + end + end + + original = @blob.download + result = @blob.variant(transformations).processed.download + assert_equal original, result + ensure + ActiveStorage.variant_transformer = @was_transformer + end end diff --git a/guides/source/configuring.md b/guides/source/configuring.md index 6e0de6b0c5e73..8a698bae9182b 100644 --- a/guides/source/configuring.md +++ b/guides/source/configuring.md @@ -3138,7 +3138,7 @@ The value must respond to Ruby's `Digest` interface. #### `config.active_storage.variant_processor` -Accepts a symbol `:mini_magick` or `:vips`, specifying whether variant transformations and blob analysis will be performed with MiniMagick or ruby-vips. +Accepts a symbol `:mini_magick`, `:vips`, or `:disabled` specifying whether or not variant transformations and blob analysis will be performed with MiniMagick or ruby-vips. The default value depends on the `config.load_defaults` target version: @@ -3153,11 +3153,22 @@ Accepts an array of classes indicating the analyzers available for Active Storag By default, this is defined as: ```ruby -config.active_storage.analyzers = [ActiveStorage::Analyzer::ImageAnalyzer::Vips, ActiveStorage::Analyzer::ImageAnalyzer::ImageMagick, ActiveStorage::Analyzer::VideoAnalyzer, ActiveStorage::Analyzer::AudioAnalyzer] +config.active_storage.analyzers = [ + ActiveStorage::Analyzer::ImageAnalyzer::Vips, + ActiveStorage::Analyzer::ImageAnalyzer::ImageMagick, + ActiveStorage::Analyzer::VideoAnalyzer, + ActiveStorage::Analyzer::AudioAnalyzer +] ``` The image analyzers can extract width and height of an image blob; the video analyzer can extract width, height, duration, angle, aspect ratio, and presence/absence of video/audio channels of a video blob; the audio analyzer can extract duration and bit rate of an audio blob. +If you want to disable analyzers, you can set this to an empty array: + +```ruby +config.active_storage.analyzers = [] +``` + #### `config.active_storage.previewers` Accepts an array of classes indicating the image previewers available in Active Storage blobs. diff --git a/railties/test/application/active_storage/analyzers_integration_test.rb b/railties/test/application/active_storage/analyzers_integration_test.rb new file mode 100644 index 0000000000000..e86150f9dd408 --- /dev/null +++ b/railties/test/application/active_storage/analyzers_integration_test.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require "isolation/abstract_unit" + +module ApplicationTests + class ActiveStorageEngineTest < ActiveSupport::TestCase + include ActiveSupport::Testing::Isolation + + include ActiveJob::TestHelper + + self.file_fixture_path = "#{RAILS_FRAMEWORK_ROOT}/activestorage/test/fixtures/files" + + def setup + build_app + + rails "active_storage:install" + + rails "generate", "model", "user", "name:string", "avatar:attachment" + rails "db:migrate" + end + + def teardown + teardown_app + end + + def test_analyzers_default + app("development") + + user = User.new(name: "Test User", avatar: file_fixture("racecar.jpg")) + + assert_enqueued_with(job: ActiveStorage::AnalyzeJob) do + user.save! + end + end + + def test_analyzers_empty + add_to_config "config.active_storage.analyzers = []" + + app("development") + + user = User.new(name: "Test User", avatar: file_fixture("racecar.jpg")) + + assert_no_enqueued_jobs do + user.save! + end + end + + def test_analyzers_not_empty + add_to_config "config.active_storage.analyzers = [ActiveStorage::Analyzer::ImageAnalyzer]" + + app("development") + + user = User.new(name: "Test User", avatar: file_fixture("racecar.jpg")) + + assert_enqueued_with(job: ActiveStorage::AnalyzeJob) do + user.save! + end + end + end +end diff --git a/railties/test/application/active_storage/engine_integration_test.rb b/railties/test/application/active_storage/engine_integration_test.rb new file mode 100644 index 0000000000000..fbd85824040e7 --- /dev/null +++ b/railties/test/application/active_storage/engine_integration_test.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require "isolation/abstract_unit" + +require "env_helpers" + +module ApplicationTests + class ActiveStorageEngineTest < ActiveSupport::TestCase + include ActiveSupport::Testing::Isolation + + include EnvHelpers + + def setup + build_app + + File.write app_path("Gemfile"), <<~GEMFILE + source "https://rubygems.org" + gem "rails", path: "#{RAILS_FRAMEWORK_ROOT}" + + gem "propshaft" + gem "importmap-rails" + gem "sqlite3" + GEMFILE + + add_to_env_config :development, "config.active_storage.logger = ActiveSupport::Logger.new(STDOUT)" + + File.open("#{app_path}/config/boot.rb", "w") do |f| + f.puts "ENV['BUNDLE_GEMFILE'] = '#{app_path}/Gemfile'" + f.puts 'require "bundler/setup"' + end + end + + def teardown + teardown_app + end + + def test_default_transformer_missing_gem_warning + output = run_command("puts ActiveStorage.variant_transformer") + + assert_includes(output, "Generating image variants require the image_processing gem. Please add `gem 'image_processing', '~> 1.2'` to your Gemfile.") + end + + def test_default_transformer_with_gem_no_warning + File.open("#{app_path}/Gemfile", "a") do |f| + f.puts <<~GEMFILE + gem "image_processing", "~> 1.2" + GEMFILE + end + + output = run_command("puts ActiveStorage.variant_transformer") + + assert_not_includes(output, "Generating image variants require the image_processing gem. Please add `gem 'image_processing', '~> 1.2'` to your Gemfile.") + assert_includes(output, "ActiveStorage::Transformers::Vips") + end + + def test_disabled_transformer_no_warning + add_to_config "config.active_storage.variant_processor = :disabled" + + output = run_command("puts ActiveStorage.variant_transformer") + + assert_not_includes(output, "Generating image variants require the image_processing gem. Please add `gem 'image_processing', '~> 1.2'` to your Gemfile.") + assert_includes(output, "ActiveStorage::Transformers::NullTransformer") + end + + private + def run_command(cmd) + Dir.chdir(app_path) do + Bundler.with_original_env do + with_rails_env "development" do + `bin/rails runner "#{cmd}" 2>&1` + end + end + end + end + end +end diff --git a/railties/test/application/configuration_test.rb b/railties/test/application/configuration_test.rb index 5a52a539eae65..0885013da43ed 100644 --- a/railties/test/application/configuration_test.rb +++ b/railties/test/application/configuration_test.rb @@ -4110,6 +4110,37 @@ class Post < ActiveRecord::Base MESSAGE end + test "ActiveStorage.analyzers default value" do + app "development" + + assert_equal [ + ActiveStorage::Analyzer::ImageAnalyzer::Vips, + ActiveStorage::Analyzer::ImageAnalyzer::ImageMagick, + ActiveStorage::Analyzer::VideoAnalyzer, + ActiveStorage::Analyzer::AudioAnalyzer + ], ActiveStorage.analyzers + end + + test "ActiveStorage.analyzers can be configured to be an empty array" do + add_to_config <<-RUBY + config.active_storage.analyzers = [] + RUBY + + app "development" + + assert_empty ActiveStorage.analyzers + end + + test "ActiveStorage.analyzers can be configured to custom analyzers" do + add_to_config <<-RUBY + config.active_storage.analyzers = [ ActiveStorage::Analyzer::ImageAnalyzer::Vips ] + RUBY + + app "development" + + assert_equal [ ActiveStorage::Analyzer::ImageAnalyzer::Vips ], ActiveStorage.analyzers + end + test "ActiveStorage.draw_routes can be configured via config.active_storage.draw_routes" do app_file "config/environments/development.rb", <<-RUBY Rails.application.configure do @@ -4147,7 +4178,7 @@ class Post < ActiveRecord::Base app "development" - assert_nil ActiveStorage.variant_processor + assert_equal :mini_magick, ActiveStorage.variant_processor end test "ActiveStorage.variant_processor uses vips by default" do From 5aedcc98e5074919be86a178a592158064ed37e8 Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Fri, 12 Sep 2025 09:07:48 +0200 Subject: [PATCH 0609/1075] Restore exact behavior of AJ Arguments.serialize In 21fac8d8eeb147bb7ff4ec25b01e0007a04462a4 I allowed `serialize` to accept all types, not just arrays to simplify some serializers, however that changed the behavior for objects other than arrays that respond to `#map`. The same effect can be achieved by calling `serialize_argument` directly instead. Since it used to be private, I marked it nodoc. --- activejob/lib/active_job/arguments.rb | 10 +++++++--- .../action_controller_parameters_serializer.rb | 2 +- .../lib/active_job/serializers/range_serializer.rb | 4 ++-- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/activejob/lib/active_job/arguments.rb b/activejob/lib/active_job/arguments.rb index ee4bb6308d5f2..763cba77e07ce 100644 --- a/activejob/lib/active_job/arguments.rb +++ b/activejob/lib/active_job/arguments.rb @@ -31,7 +31,11 @@ module Arguments # serialized without mutation are returned as-is. Arrays/Hashes are # serialized element by element. All other types are serialized using # GlobalID. - def serialize(argument) + def serialize(arguments) + arguments.map { |argument| serialize_argument(argument) } + end + + def serialize_argument(argument) # :nodoc: case argument when nil, true, false, Integer, Float # Types that can hardly be subclassed argument @@ -50,7 +54,7 @@ def serialize(argument) when GlobalID::Identification convert_to_global_id_hash(argument) when Array - argument.map { |arg| serialize(arg) } + argument.map { |arg| serialize_argument(arg) } when ActiveSupport::HashWithIndifferentAccess serialize_indifferent_hash(argument) when Hash @@ -137,7 +141,7 @@ def custom_serialized?(hash) def serialize_hash(argument) argument.each_with_object({}) do |(key, value), hash| - hash[serialize_hash_key(key)] = serialize(value) + hash[serialize_hash_key(key)] = serialize_argument(value) end end diff --git a/activejob/lib/active_job/serializers/action_controller_parameters_serializer.rb b/activejob/lib/active_job/serializers/action_controller_parameters_serializer.rb index 9c208247931a9..af666a570deec 100644 --- a/activejob/lib/active_job/serializers/action_controller_parameters_serializer.rb +++ b/activejob/lib/active_job/serializers/action_controller_parameters_serializer.rb @@ -4,7 +4,7 @@ module ActiveJob module Serializers class ActionControllerParametersSerializer < ObjectSerializer def serialize(argument) - Arguments.serialize(argument.to_h.with_indifferent_access) + Arguments.serialize_argument(argument.to_h.with_indifferent_access) end def deserialize(hash) diff --git a/activejob/lib/active_job/serializers/range_serializer.rb b/activejob/lib/active_job/serializers/range_serializer.rb index 313cbc746d4df..e947e1b4436f8 100644 --- a/activejob/lib/active_job/serializers/range_serializer.rb +++ b/activejob/lib/active_job/serializers/range_serializer.rb @@ -5,8 +5,8 @@ module Serializers class RangeSerializer < ObjectSerializer def serialize(range) super( - "begin" => Arguments.serialize(range.begin), - "end" => Arguments.serialize(range.end), + "begin" => Arguments.serialize_argument(range.begin), + "end" => Arguments.serialize_argument(range.end), "exclude_end" => range.exclude_end?, # Always boolean, no need to serialize ) end From bb30186f4fe33bf1b870210d8f88373f4b1e544f Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Fri, 12 Sep 2025 09:13:44 +0200 Subject: [PATCH 0610/1075] Optimize ActiveJob ObjectSerializer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same benchmark than in b6c472accd371de359f9ddd13c1f24e14e0d3019 Since 1f8a0c06c163696618032d1001a0bdf9e2ac1ed1 had to be reverted as it changes the order of keys in the payload, the next bext thing we can do is to precompute the base hash. The gain is modest but the patch is simple. ``` ruby 3.5.0dev (2025-08-27T10:41:07Z master 5257e1298c) +YJIT +PRISM [arm64-darwin24] Warming up -------------------------------------- aj-obj-serializer-template 23.500k i/100ms Calculating ------------------------------------- aj-obj-serializer-template 234.683k (± 0.7%) i/s (4.26 μs/i) - 1.175M in 5.007012s Comparison: aj-obj-serializer-template: 234682.8 i/s previous-commit: 223601.1 i/s - 1.05x slower ``` --- activejob/lib/active_job/serializers/object_serializer.rb | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/activejob/lib/active_job/serializers/object_serializer.rb b/activejob/lib/active_job/serializers/object_serializer.rb index c5e4e3120b05d..ad2d1c6df9edd 100644 --- a/activejob/lib/active_job/serializers/object_serializer.rb +++ b/activejob/lib/active_job/serializers/object_serializer.rb @@ -28,6 +28,11 @@ class << self delegate :serialize?, :serialize, :deserialize, to: :instance end + def initialize + super + @template = { Arguments::OBJECT_SERIALIZER_KEY => self.class.name }.freeze + end + # Determines if an argument should be serialized by a serializer. def serialize?(argument) argument.is_a?(klass) @@ -35,7 +40,7 @@ def serialize?(argument) # Serializes an argument to a JSON primitive type. def serialize(hash) - { Arguments::OBJECT_SERIALIZER_KEY => self.class.name }.merge!(hash) + @template.merge(hash) end # Deserializes an argument from a JSON primitive type. From 63166fe6233527ddbe16f741a5a49d8b0854cdc3 Mon Sep 17 00:00:00 2001 From: Sean Doyle Date: Sun, 29 Sep 2024 16:17:06 -0400 Subject: [PATCH 0611/1075] RateLimiting: support method names for `:by` and `:with` Prior to this commit, `:by` and `:with` options only supported callables. This commit aims to bring rate limiting closer in parity to callbacks declarations like `before_action` and `after_action` by supporting instance method names as well. --- actionpack/CHANGELOG.md | 15 ++++++++ .../action_controller/metal/rate_limiting.rb | 12 +++++-- .../test/controller/rate_limiting_test.rb | 35 +++++++++++++++++-- 3 files changed, 57 insertions(+), 5 deletions(-) diff --git a/actionpack/CHANGELOG.md b/actionpack/CHANGELOG.md index 890ca36d5541c..3ebd91bc28204 100644 --- a/actionpack/CHANGELOG.md +++ b/actionpack/CHANGELOG.md @@ -1,3 +1,18 @@ +* Update `ActionController::Metal::RateLimiting` to support passing method names to `:by` and `:with` + + ```ruby + class SignupsController < ApplicationController + rate_limit to: 10, within: 1.minute, with: :redirect_with_flash + + private + def redirect_with_flash + redirect_to root_url, alert: "Too many requests!" + end + end + ``` + + *Sean Doyle* + * Optimize `ActionDispatch::Http::URL.build_host_url` when protocol is included in host. When using URL helpers with a host that includes the protocol (e.g., `{ host: "https://example.com" }`), diff --git a/actionpack/lib/action_controller/metal/rate_limiting.rb b/actionpack/lib/action_controller/metal/rate_limiting.rb index a53fa6761ca1e..3cfd7604deea6 100644 --- a/actionpack/lib/action_controller/metal/rate_limiting.rb +++ b/actionpack/lib/action_controller/metal/rate_limiting.rb @@ -45,7 +45,12 @@ module ClassMethods # # class SignupsController < ApplicationController # rate_limit to: 1000, within: 10.seconds, - # by: -> { request.domain }, with: -> { redirect_to busy_controller_url, alert: "Too many signups on domain!" }, only: :new + # by: -> { request.domain }, with: :redirect_to_busy, only: :new + # + # private + # def redirect_to_busy + # redirect_to busy_controller_url, alert: "Too many signups on domain!" + # end # end # # class APIController < ApplicationController @@ -65,7 +70,8 @@ def rate_limit(to:, within:, by: -> { request.remote_ip }, with: -> { raise TooM private def rate_limiting(to:, within:, by:, with:, store:, name:, scope:) - by = instance_exec(&by) + by = by.is_a?(Symbol) ? send(by) : instance_exec(&by) + cache_key = ["rate-limit", scope, name, by].compact.join(":") count = store.increment(cache_key, 1, expires_in: within) if count && count > to @@ -78,7 +84,7 @@ def rate_limiting(to:, within:, by:, with:, store:, name:, scope:) name: name, scope: scope, cache_key: cache_key) do - instance_exec(&with) + with.is_a?(Symbol) ? send(with) : instance_exec(&with) end end end diff --git a/actionpack/test/controller/rate_limiting_test.rb b/actionpack/test/controller/rate_limiting_test.rb index 40c4983f24eee..49cb25c970868 100644 --- a/actionpack/test/controller/rate_limiting_test.rb +++ b/actionpack/test/controller/rate_limiting_test.rb @@ -15,6 +15,20 @@ def limited def limited_with head :ok end + + rate_limit to: 2, within: 2.seconds, by: :by_method, with: :head_forbidden, only: :limited_with_methods + def limited_with_methods + head :ok + end + + private + def by_method + params[:rate_limit_key] + end + + def head_forbidden + head :forbidden + end end class RateLimitedBaseController < ActionController::Base @@ -116,7 +130,7 @@ class RateLimitingTest < ActionController::TestCase end end - test "limit by" do + test "limit by callable" do get :limited_with get :limited_with get :limited_with @@ -126,13 +140,30 @@ class RateLimitingTest < ActionController::TestCase assert_response :ok end - test "limited with" do + test "limited with callable" do get :limited_with get :limited_with get :limited_with assert_response :forbidden end + test "limit by method" do + get :limited_with_methods + get :limited_with_methods + get :limited_with_methods + assert_response :forbidden + + get :limited_with_methods, params: { rate_limit_key: "other" } + assert_response :ok + end + + test "limited with method" do + get :limited_with_methods + get :limited_with_methods + get :limited_with_methods + assert_response :forbidden + end + test "cross-controller rate limit" do @controller = RateLimitedSharedOneController.new get :limited_shared_one From 3bf6ebbebd9f28e54433cfcf5435fcbe7d69aa1c Mon Sep 17 00:00:00 2001 From: Sean Doyle Date: Fri, 12 Sep 2025 14:05:50 -0400 Subject: [PATCH 0612/1075] Generalize `:rich_text_area` Capybara selector Related to [basecamp/lexxy#193][] Prepare for more Action Text-capable WYSIWYG editors by making `:rich_text_area` rely on the presence of [role="textbox"][] and [contenteditable][] HTML attributes rather than a `` element. The `` element will [set both of these HTML attributes when it connects to the document][connectedCallback]. In addition, modify the JavaScript executed by the `fill_in_rich_textarea` system test helper to first check for the presence of the `.value` property on the `` element (available since [trix@v2.1.7][]) before falling back to calling `this.editor.loadHTML(arguments[0])`. [basecamp/lexxy#193]: https://github.com/basecamp/lexxy/issues/193#issuecomment-3286323679 [role="textbox"]: https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/textbox_role [contenteditable]: https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/contenteditable [connectedCallback]: https://github.com/basecamp/trix/blob/v2.1.15/src/trix/elements/trix_editor_element.js#L485-L486 [trix@v2.1.7]: https://github.com/basecamp/trix/releases/tag/v2.1.7 --- actiontext/CHANGELOG.md | 8 ++++++++ .../lib/action_text/system_test_helper.rb | 17 ++++++++++++++--- .../test/system/system_test_helper_test.rb | 13 +++++++++++++ 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/actiontext/CHANGELOG.md b/actiontext/CHANGELOG.md index 17844a0afb97d..bfe603760c980 100644 --- a/actiontext/CHANGELOG.md +++ b/actiontext/CHANGELOG.md @@ -1,3 +1,11 @@ +* Generalize `:rich_text_area` Capybara selector + + Prepare for more Action Text-capable WYSIWYG editors by making + `:rich_text_area` rely on the presence of `[role="textbox"]` and + `[contenteditable]` HTML attributes rather than a `` element. + + *Sean Doyle* + ## Rails 8.1.0.beta1 (September 04, 2025) ## * Forward `fill_in_rich_text_area` options to Capybara diff --git a/actiontext/lib/action_text/system_test_helper.rb b/actiontext/lib/action_text/system_test_helper.rb index 3b4046623707d..bbe99d9fe6590 100644 --- a/actiontext/lib/action_text/system_test_helper.rb +++ b/actiontext/lib/action_text/system_test_helper.rb @@ -35,7 +35,13 @@ module SystemTestHelper # # # fill_in_rich_textarea "message[content]", with: "Hello world!" def fill_in_rich_textarea(locator = nil, with:, **) - find(:rich_textarea, locator, **).execute_script("this.editor.loadHTML(arguments[0])", with.to_s) + find(:rich_textarea, locator, **).execute_script(<<~JS, with.to_s) + if ("value" in this) { + this.value = arguments[0] + } else { + this.editor.loadHTML(arguments[0]) + } + JS end alias_method :fill_in_rich_text_area, :fill_in_rich_textarea end @@ -45,13 +51,18 @@ def fill_in_rich_textarea(locator = nil, with:, **) Capybara.add_selector rich_textarea do label "rich-text area" xpath do |locator| + xpath = XPath.descendant[[ + XPath.attribute(:role) == "textbox", + (XPath.attribute(:contenteditable) == "") | (XPath.attribute(:contenteditable) == "true") + ].reduce(:&)] + if locator.nil? - XPath.descendant(:"trix-editor") + xpath else input_located_by_name = XPath.anywhere(:input).where(XPath.attr(:name) == locator).attr(:id) input_located_by_label = XPath.anywhere(:label).where(XPath.string.n.is(locator)).attr(:for) - XPath.descendant(:"trix-editor").where \ + xpath.where \ XPath.attr(:id).equals(locator) | XPath.attr(:placeholder).equals(locator) | XPath.attr(:"aria-label").equals(locator) | diff --git a/actiontext/test/system/system_test_helper_test.rb b/actiontext/test/system/system_test_helper_test.rb index 3c2fa927d3866..35c6ed65613fa 100644 --- a/actiontext/test/system/system_test_helper_test.rb +++ b/actiontext/test/system/system_test_helper_test.rb @@ -10,35 +10,48 @@ def setup test "filling in a rich-text area by ID" do assert_selector :element, "trix-editor", id: "message_content" fill_in_rich_textarea "message_content", with: "Hello world!" + assert_selector :rich_text_area, "message_content", text: "Hello world!" assert_selector :field, "message[content]", with: /Hello world!/, type: "hidden" end test "filling in a rich-text area by placeholder" do assert_selector :element, "trix-editor", placeholder: "Your message here" fill_in_rich_textarea "Your message here", with: "Hello world!" + assert_selector :rich_text_area, "Your message here", text: "Hello world!" assert_selector :field, "message[content]", with: /Hello world!/, type: "hidden" end test "filling in a rich-text area by aria-label" do assert_selector :element, "trix-editor", "aria-label": "Message content aria-label" fill_in_rich_textarea "Message content aria-label", with: "Hello world!" + assert_selector :rich_text_area, "Message content aria-label", text: "Hello world!" assert_selector :field, "message[content]", with: /Hello world!/, type: "hidden" end test "filling in a rich-text area by label" do assert_selector :label, "Message content label", for: "message_content" fill_in_rich_textarea "Message content label", id: "message_content", with: "Hello world!" + assert_selector :rich_text_area, "Message content label", text: "Hello world!" assert_selector :field, "message[content]", with: /Hello world!/, type: "hidden" end test "filling in a rich-text area by input name" do assert_selector :element, "trix-editor", input: true fill_in_rich_textarea "message[content]", with: "Hello world!" + assert_selector :rich_text_area, "message[content]", text: "Hello world!" assert_selector :field, "message[content]", with: /Hello world!/, type: "hidden" end test "filling in the only rich-text area" do fill_in_rich_textarea with: "Hello world!" + assert_selector :rich_text_area, text: "Hello world!" assert_selector :field, "message[content]", with: /Hello world!/, type: "hidden" end + + test "filling in a rich-text area with nil" do + fill_in_rich_textarea "message_content", with: nil + assert_selector :rich_text_area do |rich_text_area| + assert_empty rich_text_area.text + end + end end From 217a75a6ee1089ea68e7d0eeb83e9dea2866ef44 Mon Sep 17 00:00:00 2001 From: Hartley McGuire Date: Sun, 14 Sep 2025 11:23:58 -0400 Subject: [PATCH 0613/1075] Add nodoc to MySQL::IndexDefinition Since its superclass, IndexDefinition, is nodoc, this probably should be too. --- .../connection_adapters/mysql/schema_definitions.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/activerecord/lib/active_record/connection_adapters/mysql/schema_definitions.rb b/activerecord/lib/active_record/connection_adapters/mysql/schema_definitions.rb index 1779bfeb22eed..0c8906764da00 100644 --- a/activerecord/lib/active_record/connection_adapters/mysql/schema_definitions.rb +++ b/activerecord/lib/active_record/connection_adapters/mysql/schema_definitions.rb @@ -48,7 +48,7 @@ module ColumnMethods end # = Active Record MySQL Adapter \Index Definition - class IndexDefinition < ActiveRecord::ConnectionAdapters::IndexDefinition + class IndexDefinition < ActiveRecord::ConnectionAdapters::IndexDefinition # :nodoc: attr_accessor :enabled def initialize(*args, **kwargs) From 3f0131dfb3541cbea1784cbd9e0664688d532c9c Mon Sep 17 00:00:00 2001 From: Hartley McGuire Date: Sun, 14 Sep 2025 12:03:14 -0400 Subject: [PATCH 0614/1075] Remove unused AbstractPool module It was originally [introduced][1] when moving schema caches from the connections to pools to share logic between the real ConnectionPool and the NullPool. However, the AbstractPool's implementation was later [removed][2] when refactoring SchemaCache to use pools instead of connections. [1]: 49b6b211a922f73d0b083e7e4d2f0a91bd44da90 [2]: b36f9186b0516ef17a6d29e13d2d0e736af43ef2 --- .../connection_adapters/abstract/connection_pool.rb | 6 ------ 1 file changed, 6 deletions(-) diff --git a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb index 36cf2521ec18d..6c20b6df1d04c 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb @@ -8,12 +8,7 @@ module ActiveRecord module ConnectionAdapters - module AbstractPool # :nodoc: - end - class NullPool # :nodoc: - include ConnectionAdapters::AbstractPool - class NullConfig def method_missing(...) nil @@ -229,7 +224,6 @@ def install_executor_hooks(executor = ActiveSupport::Executor) include MonitorMixin prepend QueryCache::ConnectionPoolConfiguration - include ConnectionAdapters::AbstractPool attr_accessor :automatic_reconnect, :checkout_timeout attr_reader :db_config, :max_connections, :min_connections, :max_age, :keepalive, :reaper, :pool_config, :async_executor, :role, :shard From 3afa62c54db9ef0987f25c6d623a5e90ab52f736 Mon Sep 17 00:00:00 2001 From: Hartley McGuire Date: Sun, 14 Sep 2025 14:01:35 -0400 Subject: [PATCH 0615/1075] Fix unresolvable RDoc references ``` ActionDispatch/IntegrationTest.html: `rdoc-ref:ActionDispatch::Integration::RequestHelpers)` can't be resolved for `ActionDispatch::Integration::RequestHelpers)` ActionDispatch/IntegrationTest.html: `rdoc-ref:ActionDispatch::Integration::RequestHelpers)` can't be resolved for `ActionDispatch::Integration::RequestHelpers)` ActiveRecord/AttributeAssignmentError.html: `rdoc-ref:AttributeAssignment#attributes=` can't be resolved for `ActiveRecord::Base#attributes=` ActiveRecord/AttributeAssignmentError.html: `rdoc-ref:AttributeAssignment#attributes=` can't be resolved for `ActiveRecord::Base#attributes=` ActiveRecord/Base.html: `rdoc-ref:AttributeAssignment#attributes=` can't be resolved for `ActiveRecord::Base#attributes=` ActiveRecord/Base.html: `rdoc-ref:AttributeAssignment#attributes=` can't be resolved for `ActiveRecord::Base#attributes=` ActiveRecord/Base.html: `rdoc-ref:AttributeAssignment#attributes=` can't be resolved for `ActiveRecord::Base#attributes=` ActiveRecord/Base.html: `rdoc-ref:AttributeAssignment#attributes=` can't be resolved for `ActiveRecord::Base#attributes=` ActiveRecord/MultiparameterAssignmentErrors.html: `rdoc-ref:AttributeAssignment#attributes=` can't be resolved for `ActiveRecord::Base#attributes=` ActiveRecord/MultiparameterAssignmentErrors.html: `rdoc-ref:AttributeAssignment#attributes=` can't be resolved for `ActiveRecord::Base#attributes=` ActiveRecord/Relation.html: `rdoc-ref:Core#new` can't be resolved for `new` ActiveRecord/SubclassNotFound.html: `rdoc-ref:ModelSchema::ClassMethods#inheritance_column` can't be resolved for `ActiveRecord::Base.inheritance_column` ActiveRecord/SubclassNotFound.html: `rdoc-ref:ModelSchema::ClassMethods#inheritance_column` can't be resolved for `ActiveRecord::Base.inheritance_column` ``` --- actionpack/lib/action_dispatch/testing/integration.rb | 5 ++--- activerecord/lib/active_record/base.rb | 4 ++-- activerecord/lib/active_record/errors.rb | 6 +++--- activerecord/lib/active_record/relation.rb | 2 +- 4 files changed, 8 insertions(+), 9 deletions(-) diff --git a/actionpack/lib/action_dispatch/testing/integration.rb b/actionpack/lib/action_dispatch/testing/integration.rb index ff2cd6761b414..f283660131f1e 100644 --- a/actionpack/lib/action_dispatch/testing/integration.rb +++ b/actionpack/lib/action_dispatch/testing/integration.rb @@ -604,9 +604,8 @@ def method_missing(method, ...) # end # end # - # See the [request helpers documentation] - # (rdoc-ref:ActionDispatch::Integration::RequestHelpers) for help - # on how to use `get`, etc. + # See the [request helpers documentation](rdoc-ref:ActionDispatch::Integration::RequestHelpers) + # for help on how to use `get`, etc. # # ### Changing the request encoding # diff --git a/activerecord/lib/active_record/base.rb b/activerecord/lib/active_record/base.rb index 9263750130a68..2b31845c8d12d 100644 --- a/activerecord/lib/active_record/base.rb +++ b/activerecord/lib/active_record/base.rb @@ -256,13 +256,13 @@ module ActiveRecord # :nodoc: # * AssociationTypeMismatch - The object assigned to the association wasn't of the type # specified in the association definition. # * AttributeAssignmentError - An error occurred while doing a mass assignment through the - # {ActiveRecord::Base#attributes=}[rdoc-ref:AttributeAssignment#attributes=] method. + # {ActiveRecord::Base#attributes=}[rdoc-ref:ActiveModel::AttributeAssignment#attributes=] method. # You can inspect the +attribute+ property of the exception object to determine which attribute # triggered the error. # * ConnectionNotEstablished - No connection has been established. # Use {ActiveRecord::Base.establish_connection}[rdoc-ref:ConnectionHandling#establish_connection] before querying. # * MultiparameterAssignmentErrors - Collection of errors that occurred during a mass assignment using the - # {ActiveRecord::Base#attributes=}[rdoc-ref:AttributeAssignment#attributes=] method. + # {ActiveRecord::Base#attributes=}[rdoc-ref:ActiveModel::AttributeAssignment#attributes=] method. # The +errors+ property of this exception contains an array of # AttributeAssignmentError # objects that should be inspected to determine which attributes triggered the errors. diff --git a/activerecord/lib/active_record/errors.rb b/activerecord/lib/active_record/errors.rb index 3fa42a48dd298..c4530d5a57902 100644 --- a/activerecord/lib/active_record/errors.rb +++ b/activerecord/lib/active_record/errors.rb @@ -12,7 +12,7 @@ class ActiveRecordError < StandardError # Raised when the single-table inheritance mechanism fails to locate the subclass # (for example due to improper usage of column that - # {ActiveRecord::Base.inheritance_column}[rdoc-ref:ModelSchema::ClassMethods#inheritance_column] + # {ActiveRecord::Base.inheritance_column}[rdoc-ref:ModelSchema.inheritance_column] # points to). class SubclassNotFound < ActiveRecordError end @@ -451,7 +451,7 @@ class DangerousAttributeError < ActiveRecordError UnknownAttributeError = ActiveModel::UnknownAttributeError # Raised when an error occurred while doing a mass assignment to an attribute through the - # {ActiveRecord::Base#attributes=}[rdoc-ref:AttributeAssignment#attributes=] method. + # {ActiveRecord::Base#attributes=}[rdoc-ref:ActiveModel::AttributeAssignment#attributes=] method. # The exception has an +attribute+ property that is the name of the offending attribute. class AttributeAssignmentError < ActiveRecordError attr_reader :exception, :attribute @@ -464,7 +464,7 @@ def initialize(message = nil, exception = nil, attribute = nil) end # Raised when there are multiple errors while doing a mass assignment through the - # {ActiveRecord::Base#attributes=}[rdoc-ref:AttributeAssignment#attributes=] + # {ActiveRecord::Base#attributes=}[rdoc-ref:ActiveModel::AttributeAssignment#attributes=] # method. The exception has an +errors+ property that contains an array of AttributeAssignmentError # objects, each corresponding to the error while assigning to an attribute. class MultiparameterAssignmentErrors < ActiveRecordError diff --git a/activerecord/lib/active_record/relation.rb b/activerecord/lib/active_record/relation.rb index bf4e9a5821eaf..df618c2952493 100644 --- a/activerecord/lib/active_record/relation.rb +++ b/activerecord/lib/active_record/relation.rb @@ -307,7 +307,7 @@ def create_or_find_by!(attributes, &block) end end - # Like #find_or_create_by, but calls {new}[rdoc-ref:Core#new] + # Like #find_or_create_by, but calls {new}[rdoc-ref:Core.new] # instead of {create}[rdoc-ref:Persistence::ClassMethods#create]. def find_or_initialize_by(attributes, &block) find_by(attributes) || new(attributes, &block) From 853e63977e1d1809bf8bb42fb98b62240979e7a3 Mon Sep 17 00:00:00 2001 From: Xavier Noria Date: Sun, 14 Sep 2025 23:58:53 +0200 Subject: [PATCH 0616/1075] Document THRESHOLD_TO_JUSTIFY_COMPRESSION cannot be changed. --- .../lib/active_record/encryption/encryptor.rb | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/activerecord/lib/active_record/encryption/encryptor.rb b/activerecord/lib/active_record/encryption/encryptor.rb index 07febc21ccd5e..83c9090bc9769 100644 --- a/activerecord/lib/active_record/encryption/encryptor.rb +++ b/activerecord/lib/active_record/encryption/encryptor.rb @@ -94,6 +94,18 @@ def compress? # :nodoc: private DECRYPT_ERRORS = [OpenSSL::Cipher::CipherError, Errors::EncryptedContentIntegrity, Errors::Decryption] ENCODING_ERRORS = [EncodingError, Errors::Encoding] + + # This threshold cannot be changed. + # + # Users can search for attributes encrypted with `deterministic: true`. + # That is possible because we are able to generate the message for the + # given clear text deterministically, and with that perform a regular + # string lookup in SQL. + # + # Problem is, messages may have a "c" header that is present or not + # depending on whether compression was applied on encryption. If this + # threshold was modified, the message generated for lookup could vary + # for the same clear text, and searches on exisiting data could fail. THRESHOLD_TO_JUSTIFY_COMPRESSION = 140.bytes def default_key_provider From 2446a70b0eee6424c90e5a262600c337006096ad Mon Sep 17 00:00:00 2001 From: alexkuebo Date: Mon, 15 Sep 2025 12:02:48 +0200 Subject: [PATCH 0617/1075] Removing the string option for validations In Rails 5.2 the option of passing a string to :if and :unless was removed and causes now: Passing string to be evaluated in :if and :unless conditional options is not supported. Pass a symbol for an instance method, or a lambda, proc or block, instead. (ArgumentError) --- activemodel/lib/active_model/validations.rb | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/activemodel/lib/active_model/validations.rb b/activemodel/lib/active_model/validations.rb index 100e51f4cd835..8dfc0a719aa22 100644 --- a/activemodel/lib/active_model/validations.rb +++ b/activemodel/lib/active_model/validations.rb @@ -147,14 +147,14 @@ def validates_each(*attr_names, &block) # or an array of symbols. (e.g. except: :create or # except_on: :custom_validation_context or # except_on: [:create, :custom_validation_context]) - # * :if - Specifies a method, proc or string to call to determine + # * :if - Specifies a method or proc to call to determine # if the validation should occur (e.g. if: :allow_validation, - # or if: Proc.new { |user| user.signup_step > 2 }). The method, - # proc or string should return or evaluate to a +true+ or +false+ value. - # * :unless - Specifies a method, proc, or string to call to + # or if: Proc.new { |user| user.signup_step > 2 }). The method or + # proc should return or evaluate to a +true+ or +false+ value. + # * :unless - Specifies a method or proc to call to # determine if the validation should not occur (e.g. unless: :skip_validation, # or unless: Proc.new { |user| user.signup_step <= 2 }). The - # method, proc, or string should return or evaluate to a +true+ or +false+ + # method or proc should return or evaluate to a +true+ or +false+ # value. # # NOTE: Calling +validate+ multiple times on the same method will overwrite previous definitions. From ee29930f58bf125763aeecedaa5048d95f3f1d4b Mon Sep 17 00:00:00 2001 From: Jevin Sew Date: Mon, 15 Sep 2025 16:47:49 +0400 Subject: [PATCH 0618/1075] ActiveModel::SecurePassword: configurable reset token expiry --- activemodel/CHANGELOG.md | 10 ++++++++++ .../lib/active_model/secure_password.rb | 18 +++++++++++++++--- activemodel/test/cases/secure_password_test.rb | 7 +++++++ activemodel/test/models/slow_pilot.rb | 15 +++++++++++++++ guides/source/security.md | 3 +++ .../views/passwords_mailer/reset.html.erb.tt | 4 +++- .../views/passwords_mailer/reset.text.erb.tt | 4 +++- 7 files changed, 56 insertions(+), 5 deletions(-) create mode 100644 activemodel/test/models/slow_pilot.rb diff --git a/activemodel/CHANGELOG.md b/activemodel/CHANGELOG.md index c078c2a9638b2..27c95eba2ac64 100644 --- a/activemodel/CHANGELOG.md +++ b/activemodel/CHANGELOG.md @@ -1,3 +1,13 @@ +* Add `reset_token: { expires_in: ... }` option to `has_secure_password`. + + Allows configuring the expiry duration of password reset tokens (default remains 15 minutes for backwards compatibility). + + ```ruby + has_secure_password reset_token: { expires_in: 1.hour } + ``` + + *Jevin Sew* + ## Rails 8.1.0.beta1 (September 04, 2025) ## * Add `except_on:` option for validation callbacks. diff --git a/activemodel/lib/active_model/secure_password.rb b/activemodel/lib/active_model/secure_password.rb index 521ae41a1a302..ae125fe3817fa 100644 --- a/activemodel/lib/active_model/secure_password.rb +++ b/activemodel/lib/active_model/secure_password.rb @@ -9,6 +9,8 @@ module SecurePassword # Hence need to put a restriction on password length. MAX_PASSWORD_LENGTH_ALLOWED = 72 + DEFAULT_RESET_TOKEN_EXPIRES_IN = 15.minutes + class << self attr_accessor :min_cost # :nodoc: end @@ -39,10 +41,15 @@ module ClassMethods # validations: false as an argument. This allows complete # customizability of validation behavior. # - # Finally, a password reset token that's valid for 15 minutes after issue - # is automatically configured when +reset_token+ is set to true (which it is by default) + # A password reset token (valid for 15 minutes by default) is automatically + # configured when +reset_token+ is set to true (which it is by default) # and the object responds to +generates_token_for+ (which Active Records do). # + # Finally, the reset token expiry can be customized by passing a hash to + # +has_secure_password+: + # + # has_secure_password reset_token: { expires_in: 1.hour } + # # To use +has_secure_password+, add bcrypt (~> 3.1.7) to your Gemfile: # # gem "bcrypt", "~> 3.1.7" @@ -160,7 +167,12 @@ def has_secure_password(attribute = :password, validations: true, reset_token: t # Only generate tokens for records that are capable of doing so (Active Records, not vanilla Active Models) if reset_token && respond_to?(:generates_token_for) - generates_token_for :"#{attribute}_reset", expires_in: 15.minutes do + reset_token_expires_in = reset_token.is_a?(Hash) ? reset_token[:expires_in] : DEFAULT_RESET_TOKEN_EXPIRES_IN + + silence_redefinition_of_method(:"#{attribute}_reset_token_expires_in") + define_method(:"#{attribute}_reset_token_expires_in") { reset_token_expires_in } + + generates_token_for :"#{attribute}_reset", expires_in: reset_token_expires_in do public_send(:"#{attribute}_salt")&.last(10) end diff --git a/activemodel/test/cases/secure_password_test.rb b/activemodel/test/cases/secure_password_test.rb index 21beb9bf599aa..317a3dafb5828 100644 --- a/activemodel/test/cases/secure_password_test.rb +++ b/activemodel/test/cases/secure_password_test.rb @@ -3,6 +3,7 @@ require "cases/helper" require "models/user" require "models/pilot" +require "models/slow_pilot" require "models/visitor" class SecurePasswordTest < ActiveModel::TestCase @@ -14,6 +15,7 @@ class SecurePasswordTest < ActiveModel::TestCase @user = User.new @visitor = Visitor.new @pilot = Pilot.new + @slow_pilot = SlowPilot.new # Simulate loading an existing user from the DB @existing_user = User.new @@ -340,4 +342,9 @@ class SecurePasswordTest < ActiveModel::TestCase assert_equal "finding-for-password_reset-by-999", Pilot.find_by_password_reset_token("999") assert_equal "finding-for-password_reset-by-999!", Pilot.find_by_password_reset_token!("999") end + + test "password reset token duration" do + assert_equal "password_reset-token-3600", @slow_pilot.password_reset_token + assert_equal 1.hour, @slow_pilot.password_reset_token_expires_in + end end diff --git a/activemodel/test/models/slow_pilot.rb b/activemodel/test/models/slow_pilot.rb new file mode 100644 index 0000000000000..3fc151fb2f465 --- /dev/null +++ b/activemodel/test/models/slow_pilot.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class SlowPilot + include ActiveModel::SecurePassword + + def self.generates_token_for(purpose, expires_in: nil, &) + @@expires_in = expires_in + end + + def generate_token_for(purpose) + "#{purpose}-token-#{@@expires_in}" + end + + has_secure_password reset_token: { expires_in: 1.hour } +end diff --git a/guides/source/security.md b/guides/source/security.md index 164cfcd0e9af6..6d003d8f263f9 100644 --- a/guides/source/security.md +++ b/guides/source/security.md @@ -145,6 +145,9 @@ the `/passwords/new` path and routes to the passwords controller. The `new` method of the `PasswordsController` class runs through the flow for sending a password reset email. +The link is valid for 15 minutes by default, but this can be configured with +`has_secure_password`. + The mailers for *reset password* are also set up by the generator at `app/mailers/password_mailer.rb` and render the following email to send to the user: diff --git a/railties/lib/rails/generators/rails/authentication/templates/app/views/passwords_mailer/reset.html.erb.tt b/railties/lib/rails/generators/rails/authentication/templates/app/views/passwords_mailer/reset.html.erb.tt index 5fa057af919cb..5fb5f150a159c 100644 --- a/railties/lib/rails/generators/rails/authentication/templates/app/views/passwords_mailer/reset.html.erb.tt +++ b/railties/lib/rails/generators/rails/authentication/templates/app/views/passwords_mailer/reset.html.erb.tt @@ -1,4 +1,6 @@

- You can reset your password within the next 15 minutes on + You can reset your password on <%%= link_to "this password reset page", edit_password_url(@user.password_reset_token) %>. + + This link will expire in <%%= distance_of_time_in_words(0, @user.password_reset_token_expires_in) %>.

diff --git a/railties/lib/rails/generators/rails/authentication/templates/app/views/passwords_mailer/reset.text.erb.tt b/railties/lib/rails/generators/rails/authentication/templates/app/views/passwords_mailer/reset.text.erb.tt index aca38eb2a327d..decfbab485c9c 100644 --- a/railties/lib/rails/generators/rails/authentication/templates/app/views/passwords_mailer/reset.text.erb.tt +++ b/railties/lib/rails/generators/rails/authentication/templates/app/views/passwords_mailer/reset.text.erb.tt @@ -1,2 +1,4 @@ -You can reset your password within the next 15 minutes on this password reset page: +You can reset your password on <%%= edit_password_url(@user.password_reset_token) %> + +This link will expire in <%%= distance_of_time_in_words(0, @user.password_reset_token_expires_in) %>. From 0a5faeaf71745b05d937b579a4da5bd90e0b6b58 Mon Sep 17 00:00:00 2001 From: Jacopo Beschi Date: Mon, 15 Sep 2025 19:51:59 +0200 Subject: [PATCH 0619/1075] Prevent stack overflow in ActionText PlaintextConversion (#55025) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Prevent stack overflow in ActionText PlaintextConversion In our app, we encountered edge cases where the ActionText plaintext conversion caused stack overflows. To address this, traverse a copy of the tree iteratively bottom-up, converting each node's content to plaintext using the already-converted children content. Finally, extract the result from the root node. This approach avoids recursion preventing stack overflows. * Update actiontext/lib/action_text/plain_text_conversion.rb Co-authored-by: Mike Dalessio * Update tests for list elements separated by new lines * Make it non-destructive * Update actiontext/lib/action_text/plain_text_conversion.rb Co-authored-by: zzak --------- Co-authored-by: Mike Dalessio Co-authored-by: zzak Co-authored-by: Rafael Mendonça França --- .../lib/action_text/plain_text_conversion.rb | 91 ++++++++++++------- .../test/unit/plain_text_conversion_test.rb | 2 +- 2 files changed, 61 insertions(+), 32 deletions(-) diff --git a/actiontext/lib/action_text/plain_text_conversion.rb b/actiontext/lib/action_text/plain_text_conversion.rb index 4a895ba5412ea..dc69e1309ef38 100644 --- a/actiontext/lib/action_text/plain_text_conversion.rb +++ b/actiontext/lib/action_text/plain_text_conversion.rb @@ -7,70 +7,70 @@ module PlainTextConversion extend self def node_to_plain_text(node) - remove_trailing_newlines(plain_text_for_node(node)) + BottomUpReducer.new(node).reduce do |n, child_values| + plain_text_for_node(n, child_values) + end.then(&method(:remove_trailing_newlines)) end private - def plain_text_for_node(node, index = 0) + def plain_text_for_node(node, child_values) if respond_to?(plain_text_method_for_node(node), true) - send(plain_text_method_for_node(node), node, index) + send(plain_text_method_for_node(node), node, child_values) else - plain_text_for_node_children(node) + plain_text_for_child_values(child_values) end end - def plain_text_for_node_children(node) - texts = [] - node.children.each_with_index do |child, index| - next if skippable?(child) + def plain_text_method_for_node(node) + :"plain_text_for_#{node.name}_node" + end - texts << plain_text_for_node(child, index) - end - texts.join + def plain_text_for_child_values(child_values) + child_values.join end - def skippable?(node) - node.name == "script" || node.name == "style" + def plain_text_for_unsupported_node(node, _child_values) + "" end - def plain_text_method_for_node(node) - :"plain_text_for_#{node.name}_node" + %i[ script style].each do |element| + alias_method :"plain_text_for_#{element}_node", :plain_text_for_unsupported_node end - def plain_text_for_block(node, index = 0) - "#{remove_trailing_newlines(plain_text_for_node_children(node))}\n\n" + def plain_text_for_block(node, child_values) + "#{remove_trailing_newlines(plain_text_for_child_values(child_values))}\n\n" end %i[ h1 p ].each do |element| alias_method :"plain_text_for_#{element}_node", :plain_text_for_block end - def plain_text_for_list(node, index) - "#{break_if_nested_list(node, plain_text_for_block(node))}" + def plain_text_for_list(node, child_values) + "#{break_if_nested_list(node, plain_text_for_block(node, child_values))}" end %i[ ul ol ].each do |element| alias_method :"plain_text_for_#{element}_node", :plain_text_for_list end - def plain_text_for_br_node(node, index) + def plain_text_for_br_node(node, _child_values) "\n" end - def plain_text_for_text_node(node, index) + def plain_text_for_text_node(node, _child_values) remove_trailing_newlines(node.text) end - def plain_text_for_div_node(node, index) - "#{remove_trailing_newlines(plain_text_for_node_children(node))}\n" + def plain_text_for_div_node(node, child_values) + "#{remove_trailing_newlines(plain_text_for_child_values(child_values))}\n" end - def plain_text_for_figcaption_node(node, index) - "[#{remove_trailing_newlines(plain_text_for_node_children(node))}]" + def plain_text_for_figcaption_node(node, child_values) + "[#{remove_trailing_newlines(plain_text_for_child_values(child_values))}]" end - def plain_text_for_blockquote_node(node, index) - text = plain_text_for_block(node) + def plain_text_for_blockquote_node(node, child_values) + text = plain_text_for_block(node, child_values) return "“”" if text.blank? text = text.dup @@ -79,9 +79,9 @@ def plain_text_for_blockquote_node(node, index) text end - def plain_text_for_li_node(node, index) - bullet = bullet_for_li_node(node, index) - text = remove_trailing_newlines(plain_text_for_node_children(node)) + def plain_text_for_li_node(node, child_values) + bullet = bullet_for_li_node(node) + text = remove_trailing_newlines(plain_text_for_child_values(child_values)) indentation = indentation_for_li_node(node) "#{indentation}#{bullet} #{text}\n" @@ -91,8 +91,9 @@ def remove_trailing_newlines(text) text.chomp("") end - def bullet_for_li_node(node, index) + def bullet_for_li_node(node) if list_node_name_for_li_node(node) == "ol" + index = node.parent.elements.index(node) "#{index + 1}." else "•" @@ -121,5 +122,33 @@ def break_if_nested_list(node, text) text end end + + class BottomUpReducer # :nodoc: + def initialize(node) + @node = node + @values = {} + end + + def reduce(&block) + traverse_bottom_up(@node) do |n| + child_values = @values.values_at(*n.children) + @values[n] = block.call(n, child_values) + end + @values[@node] + end + + private + def traverse_bottom_up(node, &block) + call_stack, processing_stack = [ node ], [] + + until call_stack.empty? + node = call_stack.pop + processing_stack.push(node) + call_stack.concat node.children + end + + processing_stack.reverse_each(&block) + end + end end end diff --git a/actiontext/test/unit/plain_text_conversion_test.rb b/actiontext/test/unit/plain_text_conversion_test.rb index a60111b2faddc..7889e6a4855a3 100644 --- a/actiontext/test/unit/plain_text_conversion_test.rb +++ b/actiontext/test/unit/plain_text_conversion_test.rb @@ -120,7 +120,7 @@ class ActionText::PlainTextConversionTest < ActiveSupport::TestCase "Hello world!\nHow are you?", ActionText::Fragment.wrap("
Hello world!
").tap do |fragment| node = fragment.source.children.last - 1_000.times do + 10_000.times do child = node.clone child.parent = node node = child From 11f907939e2799f13ba937b44f9fd5fa034aa7b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Mon, 15 Sep 2025 18:49:14 +0000 Subject: [PATCH 0620/1075] Return early when column are empty in WhereClause#except_predicates When we don't have any column, no filtering needs to happen. This also avoids calling `Array#extract!` twice on an empty array, which would allocate a new empty array. Fixes #55678. --- activerecord/lib/active_record/relation/where_clause.rb | 2 ++ activesupport/test/core_ext/array/extract_test.rb | 1 + 2 files changed, 3 insertions(+) diff --git a/activerecord/lib/active_record/relation/where_clause.rb b/activerecord/lib/active_record/relation/where_clause.rb index 91f17715ec515..7707843f99a21 100644 --- a/activerecord/lib/active_record/relation/where_clause.rb +++ b/activerecord/lib/active_record/relation/where_clause.rb @@ -176,6 +176,8 @@ def invert_predicate(node) end def except_predicates(columns) + return predicates if columns.empty? + attrs = columns.extract! { |node| node.is_a?(Arel::Attribute) } non_attrs = columns.extract! { |node| node.is_a?(Arel::Predications) } diff --git a/activesupport/test/core_ext/array/extract_test.rb b/activesupport/test/core_ext/array/extract_test.rb index 069ab5c8f7d73..5fb42e73a93c0 100644 --- a/activesupport/test/core_ext/array/extract_test.rb +++ b/activesupport/test/core_ext/array/extract_test.rb @@ -40,5 +40,6 @@ def test_extract_on_empty_array assert_equal [], new_empty_array assert_equal [], empty_array assert_equal array_id, empty_array.object_id + assert_not_same new_empty_array, empty_array end end From bb429032aa0daf7bad2a8e6ac17ec8c5934e6d19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Mon, 15 Sep 2025 20:56:38 +0000 Subject: [PATCH 0621/1075] Remove outdated comment See #55490. --- Gemfile | 2 -- 1 file changed, 2 deletions(-) diff --git a/Gemfile b/Gemfile index dca418b3c10fa..d423bddec817d 100644 --- a/Gemfile +++ b/Gemfile @@ -46,8 +46,6 @@ gem "uri", ">= 0.13.1", require: false gem "prism" group :rubocop do - # Rubocop has to be locked in the Gemfile because CI ignores Gemfile.lock - # We don't want rubocop to start failing whenever rubocop makes a new release. gem "rubocop", "1.79.2", require: false gem "rubocop-minitest", require: false gem "rubocop-packaging", require: false From 4a8c01c9bbd3e96060eed5a2e63580ac0e5b5b14 Mon Sep 17 00:00:00 2001 From: David Fritsch Date: Wed, 20 Mar 2024 10:27:49 -0700 Subject: [PATCH 0622/1075] Prevent autosave association with has_one defined on child class Adjusts the logic in inverse_polymorphic_association_changed? to determine if a has_one association's polymorphic association should autosave the associated record for a type change. Previously this would see the type as changed every time if the has_one is defined on a child class, since the polymorphic relationship saves the parent class as the *_type value. The new check resolves the correct class name to avoid these extra saves. Fixes #51280 --- .../lib/active_record/autosave_association.rb | 2 +- .../test/cases/autosave_association_test.rb | 37 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/activerecord/lib/active_record/autosave_association.rb b/activerecord/lib/active_record/autosave_association.rb index e1dbbd5a8d537..5e350f8e4e32b 100644 --- a/activerecord/lib/active_record/autosave_association.rb +++ b/activerecord/lib/active_record/autosave_association.rb @@ -527,7 +527,7 @@ def inverse_polymorphic_association_changed?(reflection, record) return false unless reflection.inverse_of&.polymorphic? class_name = record._read_attribute(reflection.inverse_of.foreign_type) - reflection.active_record != record.class.polymorphic_class_for(class_name) + reflection.active_record.polymorphic_name != class_name end # Saves the associated record if it's new or :autosave is enabled. diff --git a/activerecord/test/cases/autosave_association_test.rb b/activerecord/test/cases/autosave_association_test.rb index 0cbdcbd288ac9..cf157e1e1e796 100644 --- a/activerecord/test/cases/autosave_association_test.rb +++ b/activerecord/test/cases/autosave_association_test.rb @@ -41,6 +41,8 @@ require "models/cake_designer" require "models/drink_designer" require "models/cpk" +require "models/human" +require "models/face" class TestAutosaveAssociationsInGeneral < ActiveRecord::TestCase def test_autosave_works_even_when_other_callbacks_update_the_parent_model @@ -1802,6 +1804,41 @@ def test_mark_for_destruction_is_ignored_without_autosave_true assert_not_predicate ship, :valid? end + def test_should_not_saved_for_unchanged_sti_type_on_polymorphic_association + face = Class.new(Face) do + def self.name; "Face"; end + + after_save :count_saves + + def count_saves + @count ||= 0 + @count += 1 + end + end + + super_human = Class.new(SuperHuman) do + self.table_name = "humans" + def self.name; "SuperHuman"; end + + attribute :name, :string + + # Polymorphic has_one needs to be defined on the child class + has_one :polymorphic_face, class_name: "Face", as: :polymorphic_human, inverse_of: :polymorphic_human + end + + face_record = face.create! + + super_human_record = super_human.create!(name: "S. Human", polymorphic_face: face_record) + + super_human_record.update!(name: "Super Human") + + assert_equal "Human", face_record.polymorphic_human_type + assert_equal super_human_record.id, face_record.polymorphic_human_id + + # Saves on create of face and create of super human, but not update + assert_equal 2, face_record.instance_variable_get(:@count) + end + def test_recognizes_inverse_polymorphic_association_changes_with_same_foreign_key chef_a = chefs(:gordon_ramsay) chef_b = chefs(:marco_pierre_white) From 61161df5b87afbb74758d4f4a1143e620cfde653 Mon Sep 17 00:00:00 2001 From: Donal McBreen Date: Tue, 2 Sep 2025 09:34:05 +0100 Subject: [PATCH 0623/1075] Avoid clearing isolated state in live tests When testing live streaming, we don't create a new thread and we patch the `Live` module to ensure that the thread locals are not cleared afterwards. We were however always calling `ActiveSupport::IsolatedExecutionState.clear` so we were losing that state in tests. Additionally since https://github.com/rails/rails/pull/55247 calling `ActiveSupport::IsolatedExecutionState.clear` is unsafe in test code, because we are in an execution context (for the test) and the call wipes that out. This fix is not perfect as `share_with` does a shallow copy so changes from within the test streaming "thread" can leak out - I think that's a fundamental flaw in how the `Live` module and thread state interract. --- actionpack/lib/action_controller/metal/live.rb | 9 +++------ actionpack/test/controller/live_stream_test.rb | 8 ++++++++ .../lib/active_support/isolated_execution_state.rb | 7 +++++-- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/actionpack/lib/action_controller/metal/live.rb b/actionpack/lib/action_controller/metal/live.rb index 23cd5d87fe4a4..2f6013cfc14e5 100644 --- a/actionpack/lib/action_controller/metal/live.rb +++ b/actionpack/lib/action_controller/metal/live.rb @@ -275,16 +275,14 @@ def process(name) # This processes the action in a child thread. It lets us return the response # code and headers back up the Rack stack, and still process the body in # parallel with sending data to the client. - new_controller_thread { + new_controller_thread do ActiveSupport::Dependencies.interlock.running do t2 = Thread.current # Since we're processing the view in a different thread, copy the thread locals # from the main thread to the child thread. :'( locals.each { |k, v| t2[k] = v } - ActiveSupport::IsolatedExecutionState.share_with(t1) - - begin + ActiveSupport::IsolatedExecutionState.share_with(t1) do super(name) rescue => e if @_response.committed? @@ -301,13 +299,12 @@ def process(name) error = e end ensure - ActiveSupport::IsolatedExecutionState.clear clean_up_thread_locals(locals, t2) @_response.commit! end end - } + end ActiveSupport::Dependencies.interlock.permit_concurrent_loads do @_response.await_commit diff --git a/actionpack/test/controller/live_stream_test.rb b/actionpack/test/controller/live_stream_test.rb index 2dcc20012a513..d1f5c6bc371e8 100644 --- a/actionpack/test/controller/live_stream_test.rb +++ b/actionpack/test/controller/live_stream_test.rb @@ -684,6 +684,14 @@ def test_thread_locals_do_not_get_reset_in_test_environment assert_equal "aaron", Thread.current[:setting] end + + def test_isolated_state_does_not_get_reset_in_test_environment + ActiveSupport::IsolatedExecutionState[:setting] = "aaron" + + get :greet + + assert_equal "aaron", ActiveSupport::IsolatedExecutionState[:setting] + end end class BufferTest < ActionController::TestCase diff --git a/activesupport/lib/active_support/isolated_execution_state.rb b/activesupport/lib/active_support/isolated_execution_state.rb index e245b63eaee63..709aa8b457804 100644 --- a/activesupport/lib/active_support/isolated_execution_state.rb +++ b/activesupport/lib/active_support/isolated_execution_state.rb @@ -55,11 +55,14 @@ def context scope.current end - def share_with(other) + def share_with(other, &block) # Action Controller streaming spawns a new thread and copy thread locals. # We do the same here for backward compatibility, but this is very much a hack # and streaming should be rethought. - context.active_support_execution_state = other.active_support_execution_state.dup + old_state, context.active_support_execution_state = context.active_support_execution_state, other.active_support_execution_state.dup + block.call + ensure + context.active_support_execution_state = old_state end end From df49ce1fa27df7307ee2f5e7121eb39041ca8c4c Mon Sep 17 00:00:00 2001 From: Nick Pezza Date: Tue, 16 Sep 2025 14:05:37 -0400 Subject: [PATCH 0624/1075] Restore initial condition around setting of time attributes when deserializing a job After #55661 was merged a bug was reported that the behavior changed slightly. Moving the if into a method resulted in the enqueued_at and scheduled_at being set to nil if no data was passed. Instead to restore the prior behavior it should skip setting the attr if the data is missing in the job_data. --- activejob/lib/active_job/core.rb | 6 +++--- activejob/test/cases/job_serialization_test.rb | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/activejob/lib/active_job/core.rb b/activejob/lib/active_job/core.rb index 541f490137714..7a3af403f5be7 100644 --- a/activejob/lib/active_job/core.rb +++ b/activejob/lib/active_job/core.rb @@ -167,8 +167,8 @@ 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 = deserialize_time(job_data["enqueued_at"]) - self.scheduled_at = deserialize_time(job_data["scheduled_at"]) + self.enqueued_at = deserialize_time(job_data["enqueued_at"]) if job_data["enqueued_at"] + self.scheduled_at = deserialize_time(job_data["scheduled_at"]) if job_data["scheduled_at"] end # Configures the job with the given options. @@ -212,7 +212,7 @@ def arguments_serialized? def deserialize_time(time) if time.is_a?(Time) time - elsif time + else Time.iso8601(time) end end diff --git a/activejob/test/cases/job_serialization_test.rb b/activejob/test/cases/job_serialization_test.rb index 597aa1834417a..0eeaa0c47010c 100644 --- a/activejob/test/cases/job_serialization_test.rb +++ b/activejob/test/cases/job_serialization_test.rb @@ -41,6 +41,20 @@ class JobSerializationTest < ActiveSupport::TestCase end end + test "keeps scheduled_at around after deserialization if data doesnt include it" do + freeze_time + + current_time = Time.now + + job = HelloJob.new + serialized_job = job.serialize + job.set(wait_until: current_time) + + job.deserialize(serialized_job) + + assert_equal current_time, job.scheduled_at + end + test "deserializes enqueued_at when ActiveSupport.parse_json_times is true" do freeze_time From d8795bd762ab2716b6e7fd07a403566b28159cdc Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Tue, 16 Sep 2025 17:25:31 -0400 Subject: [PATCH 0625/1075] Fix `SCRIPT_NAME` handling in URL helpers for root-mounted engines (#55668) Related to changes introduced in #29898 --- actionpack/CHANGELOG.md | 7 +++++++ .../lib/action_dispatch/routing/routes_proxy.rb | 1 + railties/test/railties/mounted_engine_test.rb | 13 +++++++++++++ 3 files changed, 21 insertions(+) diff --git a/actionpack/CHANGELOG.md b/actionpack/CHANGELOG.md index 3ebd91bc28204..e10e0a7173936 100644 --- a/actionpack/CHANGELOG.md +++ b/actionpack/CHANGELOG.md @@ -1,3 +1,10 @@ +* URL helpers for engines mounted at the application root handle `SCRIPT_NAME` correctly. + + Fixed an issue where `SCRIPT_NAME` is not applied to paths generated for routes in an engine + mounted at "/". + + *Mike Dalessio* + * Update `ActionController::Metal::RateLimiting` to support passing method names to `:by` and `:with` ```ruby diff --git a/actionpack/lib/action_dispatch/routing/routes_proxy.rb b/actionpack/lib/action_dispatch/routing/routes_proxy.rb index fe9ba93cdea39..44868c5db6663 100644 --- a/actionpack/lib/action_dispatch/routing/routes_proxy.rb +++ b/actionpack/lib/action_dispatch/routing/routes_proxy.rb @@ -54,6 +54,7 @@ def method_missing(method, *args) # dependent part. def merge_script_names(previous_script_name, new_script_name) return new_script_name unless previous_script_name + new_script_name = new_script_name.chomp("/") resolved_parts = new_script_name.count("/") previous_parts = previous_script_name.count("/") diff --git a/railties/test/railties/mounted_engine_test.rb b/railties/test/railties/mounted_engine_test.rb index 9240a68f688d7..2f6114f4f3b08 100644 --- a/railties/test/railties/mounted_engine_test.rb +++ b/railties/test/railties/mounted_engine_test.rb @@ -47,6 +47,7 @@ class Engine < ::Rails::Engine @simple_plugin.write "config/routes.rb", <<-RUBY Weblog::Engine.routes.draw do get '/weblog' => "weblogs#index", as: 'weblogs' + get '/generate_weblog_route' => "weblogs#generate_weblog_route", as: 'weblog_generate_weblog_route' end RUBY @@ -55,6 +56,10 @@ class WeblogsController < ActionController::Base def index render plain: request.url end + + def generate_weblog_route + render plain: weblog.weblogs_path + end end RUBY @@ -285,6 +290,14 @@ def app # test that the Active Storage direct upload URL is added to a file field that explicitly requires it within en engine's view code get "/someone/blog/file_field_with_direct_upload_path" assert_equal "", last_response.body + + # test that correct path is generated in an engine mounted at root + get "/generate_weblog_route" + assert_equal "/weblog", last_response.body + + # test that correct path is generated in an engine mounted at root with default_url_options + get "/generate_weblog_route", {}, { "SCRIPT_NAME" => "/1234" } + assert_equal "/1234/weblog", last_response.body end test "route path for controller action when engine is mounted at root" do From 7be2540a04ace61cb619bb543d40724497d7acb9 Mon Sep 17 00:00:00 2001 From: Zack Deveau Date: Wed, 17 Sep 2025 09:05:11 -0400 Subject: [PATCH 0626/1075] warn against using arbitrary user supplied image transformations Allowing for arbitrary user supplied transformation methods or parameters has resulted in two CVEs. Although Rails attempts to prevent this risk, this behavior should not be considered officially supported and safe. --- guides/source/active_storage_overview.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/guides/source/active_storage_overview.md b/guides/source/active_storage_overview.md index e25d7f528f647..b011f9fb4967b 100644 --- a/guides/source/active_storage_overview.md +++ b/guides/source/active_storage_overview.md @@ -968,6 +968,12 @@ location. <%= image_tag user.avatar.variant(resize_to_limit: [100, 100]) %> ``` +WARNING: It should be considered unsafe to provide arbitrary user supplied +transformations or parameters to variant processors. This can potentially +enable command injection vulnerabilities in your app. It is also recommended +to implement a strict [ImageMagick security policy](https://imagemagick.org/script/security-policy.php) +when MiniMagick is the variant processor of choice. + If a variant is requested, Active Storage will automatically apply transformations depending on the image's format: From 6831b67df275e6f0ed92a829ac12a93bc435107a Mon Sep 17 00:00:00 2001 From: Nikita Vasilevsky Date: Wed, 17 Sep 2025 10:38:49 -0400 Subject: [PATCH 0627/1075] Do not issue `Benchmark.ms` deprecation when method exists in the gem --- activesupport/lib/active_support/core_ext/benchmark.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/activesupport/lib/active_support/core_ext/benchmark.rb b/activesupport/lib/active_support/core_ext/benchmark.rb index 20675e302c7bd..b5469e3328f1b 100644 --- a/activesupport/lib/active_support/core_ext/benchmark.rb +++ b/activesupport/lib/active_support/core_ext/benchmark.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "benchmark" +return if Benchmark.respond_to?(:ms) class << Benchmark def ms(&block) # :nodoc From f9a257fc186581ee54d880c04ec001444372dc04 Mon Sep 17 00:00:00 2001 From: Rob Lewis Date: Wed, 17 Sep 2025 13:33:04 -0400 Subject: [PATCH 0628/1075] Don't add id_value attribute alias when id_value is present Similar to how 'id_value' is not added as an alias to 'id' when 'id' is not present, now it won't try to add 'id_value' attribute alias when 'id_value' already exists. --- activerecord/CHANGELOG.md | 4 ++++ .../lib/active_record/attribute_methods.rb | 2 +- .../test/cases/attribute_methods_test.rb | 22 ++++++++++++++++++- .../test/fixtures/book_identifiers.yml | 11 ++++++++++ activerecord/test/models/book_identifier.rb | 5 +++++ activerecord/test/schema/schema.rb | 6 +++++ 6 files changed, 48 insertions(+), 2 deletions(-) create mode 100644 activerecord/test/fixtures/book_identifiers.yml create mode 100644 activerecord/test/models/book_identifier.rb diff --git a/activerecord/CHANGELOG.md b/activerecord/CHANGELOG.md index 2a99e1ab01300..82110b4bc8005 100644 --- a/activerecord/CHANGELOG.md +++ b/activerecord/CHANGELOG.md @@ -4,6 +4,10 @@ *Yasuo Honda*, *Lars Kanis* +* Don't add `id_value` attribute alias when attribute/column with that name already exists. + + *Rob Lewis* + ## Rails 8.1.0.beta1 (September 04, 2025) ## * Remove deprecated `:unsigned_float` and `:unsigned_decimal` column methods for MySQL. diff --git a/activerecord/lib/active_record/attribute_methods.rb b/activerecord/lib/active_record/attribute_methods.rb index e5179bf7b98d3..32891dc2a60da 100644 --- a/activerecord/lib/active_record/attribute_methods.rb +++ b/activerecord/lib/active_record/attribute_methods.rb @@ -113,7 +113,7 @@ def define_attribute_methods # :nodoc: unless abstract_class? load_schema super(attribute_names) - alias_attribute :id_value, :id if _has_attribute?("id") + alias_attribute :id_value, :id if _has_attribute?("id") && !_has_attribute?("id_value") end generate_alias_attributes diff --git a/activerecord/test/cases/attribute_methods_test.rb b/activerecord/test/cases/attribute_methods_test.rb index 605c334e36837..96b924e4f0793 100644 --- a/activerecord/test/cases/attribute_methods_test.rb +++ b/activerecord/test/cases/attribute_methods_test.rb @@ -15,6 +15,7 @@ require "models/keyboard" require "models/numeric_data" require "models/cpk" +require "models/book_identifier" class AttributeMethodsTest < ActiveRecord::TestCase include InTimeZone @@ -31,7 +32,7 @@ def serialize(time) ActiveRecord::Type.register(:epoch_timestamp, EpochTimestamp) - fixtures :topics, :developers, :companies, :computers + fixtures :topics, :developers, :companies, :computers, :book_identifiers def setup @old_matchers = ActiveRecord::Base.send(:attribute_method_patterns).dup @@ -54,6 +55,17 @@ def setup assert_includes new_topic_model.attribute_aliases, "id_value" end + test "#id_value alias is not defined if id_value column exist" do + new_book_identifier_model = Class.new(ActiveRecord::Base) do + self.table_name = "book_identifiers" + end + + new_book_identifier_model.define_attribute_methods + assert_includes new_book_identifier_model.attribute_names, "id" + assert_includes new_book_identifier_model.attribute_names, "id_value" + assert_empty new_book_identifier_model.attribute_aliases + end + test "aliasing `id` attribute allows reading the column value" do topic = Topic.create(id: 123_456, title: "title").becomes(TitlePrimaryKeyTopic) @@ -75,6 +87,14 @@ def setup assert_equal 1, topic.id_value end + test "#id_value returns the value in the id_value column, when id_value column exists" do + book_identifier = BookIdentifier.new + assert_nil book_identifier.id_value + + book_identifier = BookIdentifier.find(1) + assert_equal book_identifiers(:awdr_isbn13).id_value, book_identifier.id_value + end + test "#id_value alias is not defined if id column doesn't exist" do keyboard = Keyboard.create! diff --git a/activerecord/test/fixtures/book_identifiers.yml b/activerecord/test/fixtures/book_identifiers.yml new file mode 100644 index 0000000000000..54d270c057710 --- /dev/null +++ b/activerecord/test/fixtures/book_identifiers.yml @@ -0,0 +1,11 @@ +awdr_isbn13: + book_id: 1 + id: 1 + id_type: "ISBN-13" + id_value: "979-8888651346" + +awdr_asin: + book_id: 1 + id: 2 + id_type: "ASIN" + id_value: "B0DXPFFXD9" diff --git a/activerecord/test/models/book_identifier.rb b/activerecord/test/models/book_identifier.rb new file mode 100644 index 0000000000000..391327809a2c0 --- /dev/null +++ b/activerecord/test/models/book_identifier.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class BookIdentifier < ActiveRecord::Base + belongs_to :book +end diff --git a/activerecord/test/schema/schema.rb b/activerecord/test/schema/schema.rb index 7b37f4542388c..111b460877841 100644 --- a/activerecord/test/schema/schema.rb +++ b/activerecord/test/schema/schema.rb @@ -155,6 +155,12 @@ t.date :updated_on end + create_table :book_identifiers, id: :integer, force: true do |t| + t.references :book + t.string :id_type, null: false + t.string :id_value, null: false + end + create_table :encrypted_books, id: :integer, force: true do |t| t.references :author t.string :format From 8eeaa38dcb0d0126df836693b6557842d7ba449b Mon Sep 17 00:00:00 2001 From: Prateek Choudhary Date: Sat, 5 Jul 2025 23:27:17 +0530 Subject: [PATCH 0629/1075] Fix time attribute dirty tracking with timezone conversions When time-only attributes are used with timezone-aware settings, they can be incorrectly marked as changed due to date shifts during timezone conversion. This happens because timezone conversions can change the internal date component (e.g., from 2000-01-01 to 2000-01-02) even though the time value remains the same. This commit adds a new configuration option use_fixed_date_for_time_attributes that, when enabled, ensures time-only attributes maintain a fixed date of 2000-01-01 during timezone conversions. This prevents false positive dirty tracking while maintaining backward compatibility by defaulting to false. Example usage: config.active_record.use_fixed_date_for_time_attributes = true Fixes #55118 Co-Authored-By: Jean Boussier --- activerecord/CHANGELOG.md | 10 ++++ .../attribute_methods/time_zone_conversion.rb | 12 ++++- .../time_zone_converter_test.rb | 49 +++++++++++++++++++ 3 files changed, 69 insertions(+), 2 deletions(-) diff --git a/activerecord/CHANGELOG.md b/activerecord/CHANGELOG.md index 82110b4bc8005..f550b48f8252f 100644 --- a/activerecord/CHANGELOG.md +++ b/activerecord/CHANGELOG.md @@ -1,3 +1,13 @@ +* Fix time attribute dirty tracking with timezone conversions. + + Time-only attributes now maintain a fixed date of 2000-01-01 during timezone conversions, + preventing them from being incorrectly marked as changed due to date shifts. + + This fixes an issue where time attributes would be marked as changed when setting the same time value + due to timezone conversion causing internal date shifts. + + *Prateek Choudhary* + * Skip calling `PG::Connection#cancel` in `cancel_any_running_query` when using libpq >= 18 with pg < 1.6.0, due to incompatibility. Rollback still runs, but may take longer. diff --git a/activerecord/lib/active_record/attribute_methods/time_zone_conversion.rb b/activerecord/lib/active_record/attribute_methods/time_zone_conversion.rb index 01865e2d8305b..b0b5eb9ab7e80 100644 --- a/activerecord/lib/active_record/attribute_methods/time_zone_conversion.rb +++ b/activerecord/lib/active_record/attribute_methods/time_zone_conversion.rb @@ -21,7 +21,11 @@ def cast(value) set_time_zone_without_conversion(super) elsif value.respond_to?(:in_time_zone) begin - super(user_input_in_time_zone(value)) || super + result = super(user_input_in_time_zone(value)) || super + if result && type == :time + result = result.change(year: 2000, month: 1, day: 1) + end + result rescue ArgumentError nil end @@ -41,7 +45,11 @@ def convert_time_to_time_zone(value) return if value.nil? if value.acts_like?(:time) - value.in_time_zone + converted = value.in_time_zone + if type == :time && converted + converted = converted.change(year: 2000, month: 1, day: 1) + end + converted elsif value.respond_to?(:infinite?) && value.infinite? value else diff --git a/activerecord/test/cases/attribute_methods/time_zone_converter_test.rb b/activerecord/test/cases/attribute_methods/time_zone_converter_test.rb index dac23f1ab2b84..f42afdd9dbda0 100644 --- a/activerecord/test/cases/attribute_methods/time_zone_converter_test.rb +++ b/activerecord/test/cases/attribute_methods/time_zone_converter_test.rb @@ -2,6 +2,7 @@ require "cases/helper" require "active_support/core_ext/enumerable" +require "models/topic" module ActiveRecord module AttributeMethods @@ -15,6 +16,54 @@ def test_comparison_with_date_time_type assert_equal value, value_from_cache assert_not_equal value, "foo" end + + def test_time_attributes_with_fixed_date_normalization + old_time_zone = Time.zone + + Time.zone = "Tokyo" + + subtype = ActiveRecord::Type::Time.new + converter = ActiveRecord::AttributeMethods::TimeZoneConversion::TimeZoneConverter.new(subtype) + + time_value = converter.cast("14:30") + + assert_equal 2000, time_value.year + assert_equal 1, time_value.month + assert_equal 1, time_value.day + assert_equal 14, time_value.hour + assert_equal 30, time_value.min + + time_value2 = converter.cast("14:30") + + assert_equal time_value.year, time_value2.year + assert_equal time_value.month, time_value2.month + assert_equal time_value.day, time_value2.day + ensure + Time.zone = old_time_zone + end + + def test_time_attribute_dirty_tracking_with_fixed_date + old_time_zone = Time.zone + old_default_timezone = ActiveRecord.default_timezone + + Time.zone = "Tokyo" + ActiveRecord.default_timezone = :utc + + timezone_aware_topic = Class.new(ActiveRecord::Base) do + self.table_name = "topics" + self.time_zone_aware_attributes = true + self.time_zone_aware_types = [:datetime, :time] + attribute :bonus_time, :time + end + + topic = timezone_aware_topic.create!(bonus_time: "08:00") + topic.reload + topic.bonus_time = "08:00" + assert_not_predicate topic, :bonus_time_changed? + ensure + Time.zone = old_time_zone + ActiveRecord.default_timezone = old_default_timezone + end end end end From 0d5945820f3402ce2b3527dcddcdfa2e87e0d91f Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Thu, 18 Sep 2025 14:29:30 +0200 Subject: [PATCH 0630/1075] Fix TransitionTable#as_json compatibility with json 2.14.0 The coder is now more strict and invoke the `as_json` callback for keys, so returning a hash with numeric keys no longer works. --- Gemfile.lock | 2 +- .../lib/action_dispatch/journey/gtg/transition_table.rb | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 8c57570c51ff1..2ad0cbcf85e5c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -311,7 +311,7 @@ GEM jmespath (1.6.2) jsbundling-rails (1.3.1) railties (>= 6.0.0) - json (2.10.2) + json (2.14.0) jwt (2.10.1) base64 kamal (2.4.0) diff --git a/actionpack/lib/action_dispatch/journey/gtg/transition_table.rb b/actionpack/lib/action_dispatch/journey/gtg/transition_table.rb index a8667f84589fb..239135ec8af0d 100644 --- a/actionpack/lib/action_dispatch/journey/gtg/transition_table.rb +++ b/actionpack/lib/action_dispatch/journey/gtg/transition_table.rb @@ -110,10 +110,10 @@ def as_json(options = nil) end { - regexp_states: simple_regexp, - string_states: @string_states, - stdparam_states: @stdparam_states, - accepting: @accepting + regexp_states: simple_regexp.stringify_keys, + string_states: @string_states.stringify_keys, + stdparam_states: @stdparam_states.stringify_keys, + accepting: @accepting.stringify_keys } end From a20ee82fb14a955f55e49ae9db76779b250b3d9a Mon Sep 17 00:00:00 2001 From: Earlopain <14981592+Earlopain@users.noreply.github.com> Date: Thu, 18 Sep 2025 18:56:15 +0200 Subject: [PATCH 0631/1075] Add `image_processing` to the gemfile by default for active_storage Right now, a new rails app emits a warning because this gem is eagerly loaded. This prevents that, and also shows how to opt out of using it alltogether. --- .../action_text/install/install_generator.rb | 12 ------------ activestorage/lib/active_storage/engine.rb | 3 ++- guides/source/active_storage_overview.md | 6 ------ .../rails/generators/rails/app/templates/Gemfile.tt | 2 +- .../active_storage/engine_integration_test.rb | 8 ++++---- .../generators/action_text_install_generator_test.rb | 12 ------------ railties/test/generators/app_generator_test.rb | 2 +- 7 files changed, 8 insertions(+), 37 deletions(-) diff --git a/actiontext/lib/generators/action_text/install/install_generator.rb b/actiontext/lib/generators/action_text/install/install_generator.rb index 0dc5216975c64..43628c3dd8145 100644 --- a/actiontext/lib/generators/action_text/install/install_generator.rb +++ b/actiontext/lib/generators/action_text/install/install_generator.rb @@ -47,18 +47,6 @@ def create_actiontext_files "app/views/layouts/action_text/contents/_content.html.erb" end - def enable_image_processing_gem - if (gemfile_path = Pathname(destination_root).join("Gemfile")).exist? - say "Ensure image_processing gem has been enabled so image uploads will work (remember to bundle!)" - image_processing_regex = /gem ["']image_processing["']/ - if File.readlines(gemfile_path).grep(image_processing_regex).any? - uncomment_lines gemfile_path, image_processing_regex - else - run "bundle add --skip-install image_processing" - end - end - end - def create_migrations rails_command "railties:install:migrations FROM=active_storage,action_text", inline: true end diff --git a/activestorage/lib/active_storage/engine.rb b/activestorage/lib/active_storage/engine.rb index d74ae705563c8..53a44cd447136 100644 --- a/activestorage/lib/active_storage/engine.rb +++ b/activestorage/lib/active_storage/engine.rb @@ -112,7 +112,8 @@ class Engine < Rails::Engine # :nodoc: when /image_processing/ ActiveStorage.logger.warn <<~WARNING.squish Generating image variants require the image_processing gem. - Please add `gem 'image_processing', '~> 1.2'` to your Gemfile. + Please add `gem "image_processing", "~> 1.2"` to your Gemfile + or set `config.active_storage.variant_processor = :disabled`. WARNING else raise diff --git a/guides/source/active_storage_overview.md b/guides/source/active_storage_overview.md index e25d7f528f647..65566bc53aefe 100644 --- a/guides/source/active_storage_overview.md +++ b/guides/source/active_storage_overview.md @@ -41,12 +41,6 @@ will not install, and must be installed separately: * [ffmpeg](http://ffmpeg.org/) v3.4+ for video previews and ffprobe for video/audio analysis * [poppler](https://poppler.freedesktop.org/) or [muPDF](https://mupdf.com/) for PDF previews -Image analysis and transformations also require the `image_processing` gem. Uncomment it in your `Gemfile`, or add it if necessary: - -```ruby -gem "image_processing", ">= 1.2" -``` - TIP: Compared to libvips, ImageMagick is better known and more widely available. However, libvips can be [up to 10x faster and consume 1/10 the memory](https://github.com/libvips/libvips/wiki/Speed-and-memory-use). For JPEG files, this can be further improved by replacing `libjpeg-dev` with `libjpeg-turbo-dev`, which is [2-7x faster](https://libjpeg-turbo.org/About/Performance). WARNING: Before you install and use third-party software, make sure you understand the licensing implications of doing so. MuPDF, in particular, is licensed under AGPL and requires a commercial license for some use. diff --git a/railties/lib/rails/generators/rails/app/templates/Gemfile.tt b/railties/lib/rails/generators/rails/app/templates/Gemfile.tt index a93a298a3f838..f6ee1cc037cb1 100644 --- a/railties/lib/rails/generators/rails/app/templates/Gemfile.tt +++ b/railties/lib/rails/generators/rails/app/templates/Gemfile.tt @@ -42,7 +42,7 @@ gem "thruster", require: false <% unless skip_active_storage? -%> # Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images] -# gem "image_processing", "~> 1.2" +gem "image_processing", "~> 1.2" <% end -%> <%- if options.api? -%> diff --git a/railties/test/application/active_storage/engine_integration_test.rb b/railties/test/application/active_storage/engine_integration_test.rb index fbd85824040e7..14d3c2089adc5 100644 --- a/railties/test/application/active_storage/engine_integration_test.rb +++ b/railties/test/application/active_storage/engine_integration_test.rb @@ -37,7 +37,7 @@ def teardown def test_default_transformer_missing_gem_warning output = run_command("puts ActiveStorage.variant_transformer") - assert_includes(output, "Generating image variants require the image_processing gem. Please add `gem 'image_processing', '~> 1.2'` to your Gemfile.") + assert_includes(output, 'Generating image variants require the image_processing gem. Please add `gem "image_processing", "~> 1.2"` to your Gemfile') end def test_default_transformer_with_gem_no_warning @@ -49,16 +49,16 @@ def test_default_transformer_with_gem_no_warning output = run_command("puts ActiveStorage.variant_transformer") - assert_not_includes(output, "Generating image variants require the image_processing gem. Please add `gem 'image_processing', '~> 1.2'` to your Gemfile.") + assert_not_includes(output, 'Generating image variants require the image_processing gem. Please add `gem "image_processing", "~> 1.2"` to your Gemfile') assert_includes(output, "ActiveStorage::Transformers::Vips") end - def test_disabled_transformer_no_warning + def test_disabled_transformer_missing_gem_no_warning add_to_config "config.active_storage.variant_processor = :disabled" output = run_command("puts ActiveStorage.variant_transformer") - assert_not_includes(output, "Generating image variants require the image_processing gem. Please add `gem 'image_processing', '~> 1.2'` to your Gemfile.") + assert_not_includes(output, 'Generating image variants require the image_processing gem. Please add `gem "image_processing", "~> 1.2"` to your Gemfile') assert_includes(output, "ActiveStorage::Transformers::NullTransformer") end diff --git a/railties/test/generators/action_text_install_generator_test.rb b/railties/test/generators/action_text_install_generator_test.rb index a3754cdb12ea4..7840900b18f09 100644 --- a/railties/test/generators/action_text_install_generator_test.rb +++ b/railties/test/generators/action_text_install_generator_test.rb @@ -74,18 +74,6 @@ class ActionText::Generators::InstallGeneratorTest < Rails::Generators::TestCase assert_migration "db/migrate/create_action_text_tables.action_text.rb" end - test "uncomments image_processing gem" do - gemfile = Pathname("Gemfile").expand_path(destination_root) - gemfile.dirname.mkpath - gemfile.write(%(# gem "image_processing")) - - run_generator_instance - - assert_file gemfile do |content| - assert_equal %(gem "image_processing"), content - end - end - private def run_generator_instance @run_commands = [] diff --git a/railties/test/generators/app_generator_test.rb b/railties/test/generators/app_generator_test.rb index dd0d37d317e4d..9649daa66b2c9 100644 --- a/railties/test/generators/app_generator_test.rb +++ b/railties/test/generators/app_generator_test.rb @@ -1743,7 +1743,7 @@ def assert_load_defaults end def assert_gem_for_active_storage - assert_file "Gemfile", /^# gem "image_processing"/ + assert_gem "image_processing" end def assert_frameworks_are_not_required_when_active_storage_is_skipped From a8a80e3396276a0c6a281f72b6776d17b2e9312b Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Thu, 18 Sep 2025 20:01:12 +0200 Subject: [PATCH 0632/1075] Fix compatibility with `json` `2.14.0+` --- activesupport/lib/active_support/json/encoding.rb | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/activesupport/lib/active_support/json/encoding.rb b/activesupport/lib/active_support/json/encoding.rb index da8c2e9964de8..6facaa34f1180 100644 --- a/activesupport/lib/active_support/json/encoding.rb +++ b/activesupport/lib/active_support/json/encoding.rb @@ -147,7 +147,13 @@ class JSONGemCoderEncoder # :nodoc: json_value = value.as_json # Handle objects returning self from as_json if json_value.equal?(value) - next ::JSON::Fragment.new(::JSON.generate(json_value)) + if JSON_NATIVE_TYPES.include?(json_value.class) + # If the callback is invoked for a native type, + # it means it is hash keys, e.g. { 1 => true } + next json_value.to_s + else + next ::JSON::Fragment.new(::JSON.generate(json_value)) + end end # Handle objects not returning JSON-native types from as_json count = 5 From ea77ec1342a5eb247d0a237544fa40bed3481b7f Mon Sep 17 00:00:00 2001 From: Josua Schmid Date: Tue, 13 May 2025 21:52:08 +0200 Subject: [PATCH 0633/1075] Respect SCHEMA_FORMAT in db:schema:load Considering how we go forward in #53666, this has been an oversight when introducing the `SCHEMA_FORMAT` environment variable. --- activerecord/lib/active_record/railties/databases.rake | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/activerecord/lib/active_record/railties/databases.rake b/activerecord/lib/active_record/railties/databases.rake index 8e63fe8215d94..e3e491df372e4 100644 --- a/activerecord/lib/active_record/railties/databases.rake +++ b/activerecord/lib/active_record/railties/databases.rake @@ -466,7 +466,8 @@ db_namespace = namespace :db do desc "Load a database schema file (either db/schema.rb or db/structure.sql, depending on `ENV['SCHEMA_FORMAT']` or `config.active_record.schema_format`) into the database" task load: [:load_config, :check_protected_environments] do - ActiveRecord::Tasks::DatabaseTasks.load_schema_current(nil, ENV["SCHEMA"]) + schema_format = ENV.fetch("SCHEMA_FORMAT", ActiveRecord.schema_format).to_sym + ActiveRecord::Tasks::DatabaseTasks.load_schema_current(schema_format, ENV["SCHEMA"]) end namespace :dump do From 69c285be7a628b4645a175bf5342552dc3ede15f Mon Sep 17 00:00:00 2001 From: Hartley McGuire Date: Thu, 18 Sep 2025 16:52:55 -0400 Subject: [PATCH 0634/1075] Fix ActiveRecord vs db_config schema_format Inside `load_schema_current` is `format || db_config.schema_format` (and `db_config.schema_format` is `format || ActiveRecord.schema_format`). So the correct thing to do here is only pass in `SCHEMA_FORMAT` if given so that it can fallback to a `db_config.schema_format` and only then fallback to `ActiveRecord.schema_format`. --- activerecord/lib/active_record/railties/databases.rake | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/activerecord/lib/active_record/railties/databases.rake b/activerecord/lib/active_record/railties/databases.rake index e3e491df372e4..799571f1a125e 100644 --- a/activerecord/lib/active_record/railties/databases.rake +++ b/activerecord/lib/active_record/railties/databases.rake @@ -466,8 +466,7 @@ db_namespace = namespace :db do desc "Load a database schema file (either db/schema.rb or db/structure.sql, depending on `ENV['SCHEMA_FORMAT']` or `config.active_record.schema_format`) into the database" task load: [:load_config, :check_protected_environments] do - schema_format = ENV.fetch("SCHEMA_FORMAT", ActiveRecord.schema_format).to_sym - ActiveRecord::Tasks::DatabaseTasks.load_schema_current(schema_format, ENV["SCHEMA"]) + ActiveRecord::Tasks::DatabaseTasks.load_schema_current(ENV["SCHEMA_FORMAT"], ENV["SCHEMA"]) end namespace :dump do From ed99a9ba13add1bcfb918cfcb6018ce8d159700e Mon Sep 17 00:00:00 2001 From: Hartley McGuire Date: Thu, 18 Sep 2025 17:01:20 -0400 Subject: [PATCH 0635/1075] Make TableMetadata#associated_with non-predicate Since we're using its return value now (and not its truthiness), it doesn't make sense to be a predicate method. --- activerecord/lib/active_record/relation/predicate_builder.rb | 2 +- activerecord/lib/active_record/table_metadata.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/activerecord/lib/active_record/relation/predicate_builder.rb b/activerecord/lib/active_record/relation/predicate_builder.rb index 3a595adb96f29..11881a4f10e79 100644 --- a/activerecord/lib/active_record/relation/predicate_builder.rb +++ b/activerecord/lib/active_record/relation/predicate_builder.rb @@ -99,7 +99,7 @@ def expand_from_hash(attributes, &block) elsif value.is_a?(Hash) && !table.has_column?(key) table.associated_table(key, &block) .predicate_builder.expand_from_hash(value.stringify_keys) - elsif (associated_reflection = table.associated_with?(key)) + elsif (associated_reflection = table.associated_with(key)) # Find the foreign key when using queries such as: # Post.where(author: author) # diff --git a/activerecord/lib/active_record/table_metadata.rb b/activerecord/lib/active_record/table_metadata.rb index a70214b14b4ff..0be4aa71968c3 100644 --- a/activerecord/lib/active_record/table_metadata.rb +++ b/activerecord/lib/active_record/table_metadata.rb @@ -19,7 +19,7 @@ def has_column?(column_name) klass&.columns_hash&.key?(column_name) end - def associated_with?(table_name) + def associated_with(table_name) klass&._reflect_on_association(table_name) end From 12fc2a4137a5686a034914fe84dd3cdb809a06d9 Mon Sep 17 00:00:00 2001 From: Yasuo Honda Date: Fri, 19 Sep 2025 08:55:02 +0900 Subject: [PATCH 0636/1075] Update devcontainer to use Ruby 3.4.6 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ruby version installed with this commit: ``` vscode ➜ /workspaces/rails (use-ruby346-devcontainer) $ ruby -v ruby 3.4.6 (2025-09-16 revision dbd83256b1) +PRISM [aarch64-linux] ``` Ruby 3.4.6 is available at: https://github.com/rails/devcontainer/pkgs/container/devcontainer%2Fimages%2Fruby/516271395?tag=3.4.6 https://github.com/rails/devcontainer/commit/845288ce6ebc271909795019e3f7915e2b25f69c --- .devcontainer/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 7d620cdaef49b..8ed7dbf5ebb10 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,7 +1,7 @@ # See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.191.1/containers/ruby/.devcontainer/base.Dockerfile # [Choice] Ruby version: 3.4, 3.3, 3.2 -ARG VARIANT="3.4.5" +ARG VARIANT="3.4.6" FROM ghcr.io/rails/devcontainer/images/ruby:${VARIANT} RUN sudo apt-get update && export DEBIAN_FRONTEND=noninteractive \ From 54e6d804954b299a9c694fc11175197976fb913d Mon Sep 17 00:00:00 2001 From: Jill Klang Date: Thu, 18 Sep 2025 20:23:38 -0400 Subject: [PATCH 0637/1075] Document change to schema.rb ordering This will affect any application with a schema.rb file and makes sense to call out as a notable change. Anyone running or any other command that dumps the schema will run into it. --- guides/source/8_1_release_notes.md | 2 ++ guides/source/upgrading_ruby_on_rails.md | 3 +++ 2 files changed, 5 insertions(+) diff --git a/guides/source/8_1_release_notes.md b/guides/source/8_1_release_notes.md index 53fc60a54f61a..e472b986da201 100644 --- a/guides/source/8_1_release_notes.md +++ b/guides/source/8_1_release_notes.md @@ -127,6 +127,8 @@ Please refer to the [Changelog][active-record] for detailed changes. ### Notable changes +* The table columns inside `schema.rb` are [now sorted alphabetically.](https://github.com/rails/rails/pull/53281) + Active Storage -------------- diff --git a/guides/source/upgrading_ruby_on_rails.md b/guides/source/upgrading_ruby_on_rails.md index 1564956f63b06..dd812ebf2c3f8 100644 --- a/guides/source/upgrading_ruby_on_rails.md +++ b/guides/source/upgrading_ruby_on_rails.md @@ -82,6 +82,9 @@ Upgrading from Rails 8.0 to Rails 8.1 For more information on changes made to Rails 8.1 please see the [release notes](8_1_release_notes.html). +### The table columns inside `schema.rb` are now sorted alphabetically. + +ActiveRecord now alphabetically sorts table columns in schema.rb by default, so dumps are consistent across machines and don’t flip-flop with migration order -- meaning fewer noisy diffs. structure.sql can still be leveraged to preserve exact column order. [See #53281 for more details on alphabetizing schema changes.](https://github.com/rails/rails/pull/53281) Upgrading from Rails 7.2 to Rails 8.0 ------------------------------------- From 4483c237edf0d091239adb5e9264e5084cfbc575 Mon Sep 17 00:00:00 2001 From: Jerome Dalbert Date: Sun, 29 Dec 2024 15:26:41 +0100 Subject: [PATCH 0638/1075] Make `rails new --quiet` fully quiet By making sure Rails commands executed during `rails new --quiet`, such as `importmap:install`, are quietly ran as well. Co-Authored-By: zzak --- railties/lib/rails/generators/app_base.rb | 5 +++++ railties/test/generators/actions_test.rb | 7 +++++++ 2 files changed, 12 insertions(+) diff --git a/railties/lib/rails/generators/app_base.rb b/railties/lib/rails/generators/app_base.rb index 623c8ebad8755..0284c6709e30d 100644 --- a/railties/lib/rails/generators/app_base.rb +++ b/railties/lib/rails/generators/app_base.rb @@ -657,6 +657,11 @@ def cable_gemfile_entry end end + def rails_command(command, command_options = {}) + command_options[:capture] = true if options[:quiet] + super + end + def bundle_install? !(options[:skip_bundle] || options[:pretend]) end diff --git a/railties/test/generators/actions_test.rb b/railties/test/generators/actions_test.rb index 56cec3177de9f..b0c90ef935054 100644 --- a/railties/test/generators/actions_test.rb +++ b/railties/test/generators/actions_test.rb @@ -569,6 +569,13 @@ def test_initializer_should_write_date_to_file_with_block_in_config_initializers assert_match(/1234567890/, error.message) end + test "rails_command with quiet option" do + generator(default_arguments, quiet: true) + assert_runs "rails new myapp", capture: true do + action :rails_command, "new myapp" + end + end + test "route should add route" do run_generator route_commands = ['get "foo"', 'get "bar"', 'get "baz"'] From 8c1db574e8de135a9d3de887e4d6436ec6792363 Mon Sep 17 00:00:00 2001 From: Heinrich Lee Yu Date: Thu, 18 Sep 2025 22:58:43 +0800 Subject: [PATCH 0639/1075] Fix query cache for pinned connections in multi threaded transactional tests Fix: https://github.com/rails/rails/issues/55689 Fix: https://github.com/rails/rails/pull/55696 When a pinned connection is used across separate threads, each thread should have its own query cache store. Co-Authored-By: Jean Boussier --- activerecord/CHANGELOG.md | 9 ++++++++ .../abstract/connection_pool.rb | 3 +++ .../abstract/query_cache.rb | 23 ++++++++++++++----- .../connection_adapters/abstract_adapter.rb | 2 ++ activerecord/test/cases/query_cache_test.rb | 18 +++++++++++++-- 5 files changed, 47 insertions(+), 8 deletions(-) diff --git a/activerecord/CHANGELOG.md b/activerecord/CHANGELOG.md index f550b48f8252f..1085a910c388d 100644 --- a/activerecord/CHANGELOG.md +++ b/activerecord/CHANGELOG.md @@ -1,3 +1,12 @@ +* Fix query cache for pinned connections in multi threaded transactional tests + + When a pinned connection is used across separate threads, they now use a separate cache store + for each thread. + + This improve accuracy of system tests, and any test using multiple threads. + + *Heinrich Lee Yu*, *Jean Boussier* + * Fix time attribute dirty tracking with timezone conversions. Time-only attributes now maintain a fixed date of 2000-01-01 during timezone conversions, diff --git a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb index 6c20b6df1d04c..1a9f3c3d0b726 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb @@ -31,6 +31,7 @@ def schema_reflection end def schema_cache; end + def query_cache; end def connection_descriptor; end def checkin(_); end def remove(_); end @@ -362,6 +363,7 @@ def pin_connection!(lock_thread) # :nodoc: end @pinned_connection.lock_thread = ActiveSupport::IsolatedExecutionState.context if lock_thread + @pinned_connection.pinned = true @pinned_connection.verify! # eagerly validate the connection @pinned_connection.begin_transaction joinable: false, _lazy: false end @@ -384,6 +386,7 @@ def unpin_connection! # :nodoc: end if @pinned_connection.nil? + connection.pinned = false connection.steal! connection.lock_thread = nil checkin(connection) diff --git a/activerecord/lib/active_record/connection_adapters/abstract/query_cache.rb b/activerecord/lib/active_record/connection_adapters/abstract/query_cache.rb index a6773c91f675d..187035e89e0a8 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/query_cache.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/query_cache.rb @@ -209,15 +209,26 @@ def query_cache end end - attr_accessor :query_cache - def initialize(*) super @query_cache = nil end + attr_writer :query_cache + + def query_cache + if @pinned && @owner != ActiveSupport::IsolatedExecutionState.context + # With transactional tests, if the connection is pinned, any thread + # other than the one that pinned the connection need to go through the + # query cache pool, so each thread get a different cache. + pool.query_cache + else + @query_cache + end + end + def query_cache_enabled - @query_cache&.enabled? + query_cache&.enabled? end # Enable the query cache within the block. @@ -256,7 +267,7 @@ def select_all(arel, name = nil, binds = [], preparable: nil, async: false, allo # If arel is locked this is a SELECT ... FOR UPDATE or somesuch. # Such queries should not be cached. - if @query_cache&.enabled? && !(arel.respond_to?(:locked) && arel.locked) + if query_cache_enabled && !(arel.respond_to?(:locked) && arel.locked) sql, binds, preparable, allow_retry = to_sql_and_binds(arel, binds, preparable, allow_retry) if async @@ -280,7 +291,7 @@ def lookup_sql_cache(sql, name, binds) result = nil @lock.synchronize do - result = @query_cache[key] + result = query_cache[key] end if result @@ -299,7 +310,7 @@ def cache_sql(sql, name, binds) hit = true @lock.synchronize do - result = @query_cache.compute_if_absent(key) do + result = query_cache.compute_if_absent(key) do hit = false yield end diff --git a/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb b/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb index e835a3e804b53..6d4626e05a298 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb @@ -43,6 +43,7 @@ class AbstractAdapter attr_reader :pool attr_reader :visitor, :owner, :logger, :lock attr_accessor :allow_preconnect + attr_accessor :pinned # :nodoc: alias :in_use? :owner def pool=(value) @@ -153,6 +154,7 @@ def initialize(config_or_deprecated_connection, deprecated_logger = nil, depreca end @owner = nil + @pinned = false @pool = ActiveRecord::ConnectionAdapters::NullPool.new @idle_since = Process.clock_gettime(Process::CLOCK_MONOTONIC) @allow_preconnect = true diff --git a/activerecord/test/cases/query_cache_test.rb b/activerecord/test/cases/query_cache_test.rb index 448133f7632a9..942c171c586ef 100644 --- a/activerecord/test/cases/query_cache_test.rb +++ b/activerecord/test/cases/query_cache_test.rb @@ -729,16 +729,28 @@ def test_clear_query_cache_is_called_on_all_connections ActiveRecord::Base.lease_connection.enable_query_cache! assert_cache :clean + main_thread_cache = ActiveRecord::Base.lease_connection.query_cache + assert_same main_thread_cache, ActiveRecord::Base.lease_connection.query_cache + thread_a = Thread.new do middleware { |env| assert_cache :clean + + # In a background thread, the cache instance must stay consistent but be different from the main + # thread. + background_thread_cache = ActiveRecord::Base.lease_connection.query_cache + assert_same background_thread_cache, ActiveRecord::Base.lease_connection.query_cache + assert_not_same main_thread_cache, ActiveRecord::Base.lease_connection.query_cache [200, {}, nil] }.call({}) end thread_a.join + + assert_same main_thread_cache, ActiveRecord::Base.lease_connection.query_cache ensure ActiveRecord::Base.connection_pool.unpin_connection! + assert_same main_thread_cache, ActiveRecord::Base.lease_connection.query_cache end end @@ -755,7 +767,7 @@ def test_clear_query_cache_is_called_on_all_connections thread_a = Thread.new do middleware { |env| - assert_cache :dirty # The cache is shared with the main thread + assert_cache :clean Post.first assert_cache :dirty @@ -845,10 +857,12 @@ def assert_cache(state, connection = ActiveRecord::Base.lease_connection) end when :clean assert connection.query_cache_enabled, "cache should be on" + assert_not_nil connection.query_cache assert_predicate connection.query_cache, :empty?, "cache should be empty" when :dirty assert connection.query_cache_enabled, "cache should be on" - assert_not connection.query_cache.empty?, "cache should be dirty" + assert_not_nil connection.query_cache + assert_not_predicate connection.query_cache, :empty?, "cache should be dirty" else raise "unknown state" end From 1055c76b7c49f58aaf30f1bffd578e5f6452ab11 Mon Sep 17 00:00:00 2001 From: Sean Doyle Date: Fri, 10 May 2024 18:05:20 -0400 Subject: [PATCH 0640/1075] Make `ActiveModel::Serializers::JSON#from_json` compatible with `#assign_attributes` Prior to this commit, models that inherit from [ActiveModel::AttributeAssignment][] (either directly or through including [ActiveModel::API][]) lose their ability to override the attribute assignment utilized during calls to [ActiveModel::Serializers::JSON#from_json][]. Incidentally, `#from_json` calls `#attributes=` (instead of `#assign_attributes`), whereas models that inherit from `ActiveModel::AttributeAssignment` have `#attributes=` [automatically aliased to `#assign_attributes`][alias]. This has two unintended side effects: 1. calls to `#from_json` will never invoke `#assign_attributes` overrides, since they invoke `#attributes=` directly 2. overrides to `#assign_attributes` won't have any effects on `#attributes=`, since that alias is defined on the original implementation This commit attempts to remedy that issue by attempting to call `#assign_attributes` first before falling back to `#attributes=`. A change-free solution would be to encourage (through documentation) a corresponding `alias :attributes= assign_attributes` line any time models override `assign_attributes`. [ActiveModel::AttributeAssignment]: https://edgeapi.rubyonrails.org/classes/ActiveModel/AttributeAssignment.html [ActiveModel::API]: https://edgeapi.rubyonrails.org/classes/ActiveModel/API.html [ActiveModel::Serializers::JSON#from_json]: https://edgeapi.rubyonrails.org/classes/ActiveModel/Serializers/JSON.html#method-i-from_json [alias]: https://github.com/rails/rails/blob/be0cb4e8f9aa0b105ddd035061202a5d23491b5a/activemodel/lib/active_model/attribute_assignment.rb#L37 --- activemodel/CHANGELOG.md | 4 ++++ .../lib/active_model/serializers/json.rb | 8 +++++++- .../serializers/json_serialization_test.rb | 20 +++++++++++++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/activemodel/CHANGELOG.md b/activemodel/CHANGELOG.md index c078c2a9638b2..e2166c6392807 100644 --- a/activemodel/CHANGELOG.md +++ b/activemodel/CHANGELOG.md @@ -1,3 +1,7 @@ +* Make `ActiveModel::Serializers::JSON#from_json` compatible with `#assign_attributes` + + *Sean Doyle* + ## Rails 8.1.0.beta1 (September 04, 2025) ## * Add `except_on:` option for validation callbacks. diff --git a/activemodel/lib/active_model/serializers/json.rb b/activemodel/lib/active_model/serializers/json.rb index c6cff11e5ecb4..2879eb2258504 100644 --- a/activemodel/lib/active_model/serializers/json.rb +++ b/activemodel/lib/active_model/serializers/json.rb @@ -146,7 +146,13 @@ def as_json(options = nil) def from_json(json, include_root = include_root_in_json) hash = ActiveSupport::JSON.decode(json) hash = hash.values.first if include_root - self.attributes = hash + + if respond_to?(:assign_attributes) + assign_attributes(hash) + else + self.attributes = hash + end + self end end diff --git a/activemodel/test/cases/serializers/json_serialization_test.rb b/activemodel/test/cases/serializers/json_serialization_test.rb index 09e66ef537f52..e7919495a9bb4 100644 --- a/activemodel/test/cases/serializers/json_serialization_test.rb +++ b/activemodel/test/cases/serializers/json_serialization_test.rb @@ -6,6 +6,14 @@ require "active_support/core_ext/object/instance_variables" class JsonSerializationTest < ActiveModel::TestCase + class CamelContact < Contact + include ActiveModel::AttributeAssignment + + def assign_attributes(attributes) + super(attributes.deep_transform_keys(&:underscore)) + end + end + def setup @contact = Contact.new @contact.name = "Konata Izumi" @@ -256,6 +264,18 @@ def @contact.favorite_quote; "Constraints are liberating"; end assert_equal result.preferences, @contact.preferences end + test "from_json supports models that include ActiveModel::AttributeAssignment and override assign_attributes" do + serialized = @contact.as_json + serialized.deep_transform_keys! { |key| key.camelize(:lower) } + result = CamelContact.new.from_json(serialized.to_json) + + assert_equal result.name, @contact.name + assert_equal result.age, @contact.age + assert_equal Time.parse(result.created_at), @contact.created_at + assert_equal result.awesome, @contact.awesome + assert_equal result.preferences, @contact.preferences + end + test "custom as_json should be honored when generating json" do def @contact.as_json(options = nil); { name: name, created_at: created_at }; end json = @contact.to_json From f5f3fe31120cb359ebacc4e794b95afbda44339b Mon Sep 17 00:00:00 2001 From: Hartley McGuire Date: Fri, 19 Sep 2025 13:08:54 -0400 Subject: [PATCH 0641/1075] Followup #55702 --- guides/source/upgrading_ruby_on_rails.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/guides/source/upgrading_ruby_on_rails.md b/guides/source/upgrading_ruby_on_rails.md index dd812ebf2c3f8..b910e2eccf6e9 100644 --- a/guides/source/upgrading_ruby_on_rails.md +++ b/guides/source/upgrading_ruby_on_rails.md @@ -84,7 +84,7 @@ For more information on changes made to Rails 8.1 please see the [release notes] ### The table columns inside `schema.rb` are now sorted alphabetically. -ActiveRecord now alphabetically sorts table columns in schema.rb by default, so dumps are consistent across machines and don’t flip-flop with migration order -- meaning fewer noisy diffs. structure.sql can still be leveraged to preserve exact column order. [See #53281 for more details on alphabetizing schema changes.](https://github.com/rails/rails/pull/53281) +Active Record now alphabetically sorts table columns in `schema.rb` by default, so dumps are consistent across machines and don’t flip-flop with migration order -- meaning fewer noisy diffs. `structure.sql` can still be leveraged to preserve exact column order. [See #53281 for more details on alphabetizing schema changes.](https://github.com/rails/rails/pull/53281) Upgrading from Rails 7.2 to Rails 8.0 ------------------------------------- From 321b3402f8ed1ce31bdd3c4368161ba9e6139c20 Mon Sep 17 00:00:00 2001 From: Jan Grodowski Date: Thu, 24 Jul 2025 15:57:17 +0200 Subject: [PATCH 0642/1075] [Fix #55708] Use process time instead of Time.now in FileUpdateChecker Prevents uneccesary reloading in tests using time travel helpers --- activesupport/CHANGELOG.md | 4 +++ .../lib/active_support/file_update_checker.rb | 2 +- .../test/file_update_checker_test.rb | 31 +++++++++++++++++++ 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/activesupport/CHANGELOG.md b/activesupport/CHANGELOG.md index 46b4400c76a48..d11614b14a724 100644 --- a/activesupport/CHANGELOG.md +++ b/activesupport/CHANGELOG.md @@ -1,3 +1,7 @@ +* `ActiveSupport::FileUpdateChecker` does not depend on `Time.now` to prevent unecessary reloads with time travel test helpers + + *Jan Grodowski* + ## Rails 8.1.0.beta1 (September 04, 2025) ## * Add `ActiveSupport::Cache::Store#namespace=` and `#namespace`. diff --git a/activesupport/lib/active_support/file_update_checker.rb b/activesupport/lib/active_support/file_update_checker.rb index 5fc9be8341dc3..93acc09cbc2bf 100644 --- a/activesupport/lib/active_support/file_update_checker.rb +++ b/activesupport/lib/active_support/file_update_checker.rb @@ -123,7 +123,7 @@ def updated_at(paths) # healthy to consider this edge case because with mtimes in the future # reloading is not triggered. def max_mtime(paths) - time_now = Time.now + time_now = Time.at(0, Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond), :nanosecond) max_mtime = nil # Time comparisons are performed with #compare_without_coercion because diff --git a/activesupport/test/file_update_checker_test.rb b/activesupport/test/file_update_checker_test.rb index 987cb56672182..1c9abfb160fa2 100644 --- a/activesupport/test/file_update_checker_test.rb +++ b/activesupport/test/file_update_checker_test.rb @@ -14,4 +14,35 @@ def touch(files) sleep 0.1 # let's wait a bit to ensure there's a new mtime super end + + test "should not reload files that appear in the future due to time travel" do + i = 0 + + checker = new_checker(tmpfiles) { i += 1 } + touch(tmpfiles) + + assert checker.execute_if_updated + assert_equal 1, i + + original_last_update_at = checker.instance_variable_get(:@last_update_at) + assert original_last_update_at > Time.utc(2020, 1, 1), "Expected @last_update_at to be recent" + + # Travel to the past, making current files appear to be in the future + travel_to Time.utc(2020, 1, 1) do + # With the old implementation with Time.new, this would corrupt @last_update_at to Time.at(0) + # because max_mtime would use stubbed Time.now and skip all files as "future" in max_mtime, returning nil. + # With Process.clock_gettime, the state should be preserved during time travel. + checker.execute + end + + assert_not checker.updated?, "Should not reload after time travel when state is preserved" + + final_state = checker.instance_variable_get(:@last_update_at) + assert_not_equal Time.at(0), final_state, + "State should not be corrupted after time travel" + + touch(tmpfiles) + assert checker.execute_if_updated + assert_equal 3, i + end end From ed9b92e48347c7e5c94941e81cd59ea495196ffa Mon Sep 17 00:00:00 2001 From: Hartley McGuire Date: Wed, 17 Sep 2025 00:21:09 -0400 Subject: [PATCH 0643/1075] Use PG::Connection#close_prepared when available pgbouncer does not support "DEALLOCATE #{key}", but it does support "protocol level Close" (#close_prepared) starting in version 1.21. This commit changes the PostgreSQL adapter to use #close_prepared (when available) to better support pgbouncer. Co-authored-by: CommanderKeynes --- Gemfile.lock | 8 +++++++- activerecord/CHANGELOG.md | 8 ++++++++ .../connection_adapters/postgresql_adapter.rb | 18 ++++++++++++++++-- 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 2ad0cbcf85e5c..d4cf1c83fb68e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -425,7 +425,13 @@ GEM ast (~> 2.4.1) racc path_expander (1.1.3) - pg (1.5.9) + pg (1.6.2) + pg (1.6.2-aarch64-linux) + pg (1.6.2-aarch64-linux-musl) + pg (1.6.2-arm64-darwin) + pg (1.6.2-x86_64-darwin) + pg (1.6.2-x86_64-linux) + pg (1.6.2-x86_64-linux-musl) pp (0.6.2) prettyprint prettyprint (0.2.0) diff --git a/activerecord/CHANGELOG.md b/activerecord/CHANGELOG.md index 1085a910c388d..0b6f4679e32c8 100644 --- a/activerecord/CHANGELOG.md +++ b/activerecord/CHANGELOG.md @@ -1,3 +1,11 @@ +* Use `PG::Connection#close_prepared` (protocol level Close) to deallocate + prepared statements when available. + + To enable its use, you must have pg >= 1.6.0, libpq >= 17, and a PostgreSQL + database version >= 17. + + *Hartley McGuire*, *Andrew Jackson* + * Fix query cache for pinned connections in multi threaded transactional tests When a pinned connection is used across separate threads, they now use a separate cache store diff --git a/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb b/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb index 18de3894e26de..334b8ef5dacef 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb @@ -288,6 +288,16 @@ def supports_native_partitioning? # :nodoc: database_version >= 10_00_00 # >= 10.0 end + if PG::Connection.method_defined?(:close_prepared) # pg 1.6.0 & libpq 17 + def supports_close_prepared? # :nodoc: + database_version >= 17_00_00 + end + else + def supports_close_prepared? # :nodoc: + false + end + end + def index_algorithms { concurrently: "CONCURRENTLY" } end @@ -309,8 +319,12 @@ def dealloc(key) # accessed while holding the connection's lock. (And we # don't need the complication of with_raw_connection because # a reconnect would invalidate the entire statement pool.) - if conn = @connection.instance_variable_get(:@raw_connection) - conn.query "DEALLOCATE #{key}" if conn.status == PG::CONNECTION_OK + if (conn = @connection.instance_variable_get(:@raw_connection)) && conn.status == PG::CONNECTION_OK + if @connection.supports_close_prepared? + conn.close_prepared key + else + conn.query "DEALLOCATE #{key}" + end end rescue PG::Error end From 2d60f36ff57a7b0cb38cca274b02906d136877ae Mon Sep 17 00:00:00 2001 From: Anton Kandratski Date: Mon, 26 May 2025 16:16:58 +0300 Subject: [PATCH 0644/1075] Add `only_columns` to Active Record Opposite behavior to `ignored_columns`. --- activerecord/CHANGELOG.md | 10 ++++++++ .../lib/active_record/model_schema.rb | 25 ++++++++++++++++++- activerecord/test/cases/base_test.rb | 25 +++++++++++++++++++ activerecord/test/models/developer.rb | 6 +++++ 4 files changed, 65 insertions(+), 1 deletion(-) diff --git a/activerecord/CHANGELOG.md b/activerecord/CHANGELOG.md index 0b6f4679e32c8..9f8371740a35c 100644 --- a/activerecord/CHANGELOG.md +++ b/activerecord/CHANGELOG.md @@ -1,3 +1,13 @@ +* Add `ActiveRecord::Base.only_columns` + + Similar in use case to `ignored_columns` but listing columns to consider rather than the ones + to ignore. + + Can be useful when working with a legacy or shared database schema, or to make safe schema change + in two deploys rather than three. + + *Anton Kandratski* + * Use `PG::Connection#close_prepared` (protocol level Close) to deallocate prepared statements when available. diff --git a/activerecord/lib/active_record/model_schema.rb b/activerecord/lib/active_record/model_schema.rb index 35f3f52822eb2..7f69548fe0602 100644 --- a/activerecord/lib/active_record/model_schema.rb +++ b/activerecord/lib/active_record/model_schema.rb @@ -181,6 +181,7 @@ module ModelSchema self.protected_environments = ["production"] self.ignored_columns = [].freeze + self.only_columns = [].freeze delegate :type_for_attribute, :column_for_attribute, to: :class @@ -334,6 +335,12 @@ def ignored_columns @ignored_columns || superclass.ignored_columns end + # The list of columns names the model should allow. Only columns are used to define + # attribute accessors, and are referenced in SQL queries. + def only_columns + @only_columns || superclass.only_columns + end + # Sets the columns names the model should ignore. Ignored columns won't have attribute # accessors defined, and won't be referenced in SQL queries. # @@ -366,10 +373,17 @@ def ignored_columns # user = Project.create!(name: "First Project") # user.category # => raises NoMethodError def ignored_columns=(columns) + check_model_columns(@only_columns.present?) reload_schema_from_cache @ignored_columns = columns.map(&:to_s).freeze end + def only_columns=(columns) + check_model_columns(@ignored_columns.present?) + reload_schema_from_cache + @only_columns = columns.map(&:to_s).freeze + end + def sequence_name if base_class? @sequence_name ||= reset_sequence_name @@ -579,6 +593,7 @@ def inherited(child_class) child_class.reload_schema_from_cache(false) child_class.class_eval do @ignored_columns = nil + @only_columns = nil end end @@ -592,7 +607,11 @@ def load_schema! end columns_hash = schema_cache.columns_hash(table_name) - columns_hash = columns_hash.except(*ignored_columns) unless ignored_columns.empty? + if only_columns.present? + columns_hash = columns_hash.slice(*only_columns) + elsif ignored_columns.present? + columns_hash = columns_hash.except(*ignored_columns) + end @columns_hash = columns_hash.freeze _default_attributes # Precompute to cache DB-dependent attribute types @@ -631,6 +650,10 @@ def type_for_column(connection, column) type end + + def check_model_columns(columns_present) + raise ArgumentError, "You can not use both only_columns and ignored_columns in the same model." if columns_present + end end end end diff --git a/activerecord/test/cases/base_test.rb b/activerecord/test/cases/base_test.rb index 54ada82e29791..008f1de66b193 100644 --- a/activerecord/test/cases/base_test.rb +++ b/activerecord/test/cases/base_test.rb @@ -1833,6 +1833,31 @@ def test_default_values_are_deeply_dupped assert_respond_to SymbolIgnoredDeveloper.new, :last_name? end + test "permitted columns have attribute methods" do + assert_respond_to OnlyColumnsDeveloper.new, OnlyColumnsDeveloper.primary_key + assert_respond_to OnlyColumnsDeveloper.new, :name + assert_respond_to OnlyColumnsDeveloper.new, :name= + assert_respond_to OnlyColumnsDeveloper.new, :name? + assert_respond_to OnlyColumnsDeveloper.new, :salary + assert_respond_to OnlyColumnsDeveloper.new, :salary= + assert_respond_to OnlyColumnsDeveloper.new, :salary? + assert_respond_to OnlyColumnsDeveloper.new, :firm_id + assert_respond_to OnlyColumnsDeveloper.new, :firm_id= + assert_respond_to OnlyColumnsDeveloper.new, :firm_id? + assert_respond_to OnlyColumnsDeveloper.new, :mentor_id + assert_respond_to OnlyColumnsDeveloper.new, :mentor_id= + assert_respond_to OnlyColumnsDeveloper.new, :mentor_id? + end + + test "not permitted columns have not attribute methods" do + assert_not_respond_to OnlyColumnsDeveloper.new, :first_name + assert_not_respond_to OnlyColumnsDeveloper.new, :first_name= + assert_not_respond_to OnlyColumnsDeveloper.new, :first_name? + assert_not_respond_to OnlyColumnsDeveloper.new, :legacy_created_at + assert_not_respond_to OnlyColumnsDeveloper.new, :legacy_created_at= + assert_not_respond_to OnlyColumnsDeveloper.new, :legacy_created_at? + end + test "ignored columns are stored as an array of string" do assert_equal(%w(first_name last_name), Developer.ignored_columns) assert_equal(%w(first_name last_name), SymbolIgnoredDeveloper.ignored_columns) diff --git a/activerecord/test/models/developer.rb b/activerecord/test/models/developer.rb index 581d5442b0c6a..77def85bd2f97 100644 --- a/activerecord/test/models/developer.rb +++ b/activerecord/test/models/developer.rb @@ -380,3 +380,9 @@ class AuditRequiredDeveloper < ActiveRecord::Base self.table_name = "developers" has_many :required_audit_logs, class_name: "AuditLogRequired" end + +class OnlyColumnsDeveloper < ActiveRecord::Base + self.table_name = "developers" + self.only_columns = %w[name salary firm_id mentor_id] + has_many :required_audit_logs, class_name: "AuditLogRequired" +end From 1fd5d620c6b332f132ad771a1c62d97dc920b303 Mon Sep 17 00:00:00 2001 From: zzak Date: Sat, 20 Sep 2025 13:45:52 +0900 Subject: [PATCH 0645/1075] Swallow error if libvips or ruby-vips gem are missing The previous behavior would be to raise an error only if the gem was missing, this would happen at Runtime before refactoring to startup. But if trying to boot Rails without libvips installed, this will raise the following error: ``` /home/zzak/.rbenv/versions/3.5.0/lib/ruby/gems/3.5.0+4/gems/ffi-1.17.2/lib/ffi/dynamic_library.rb:94:in 'FFI::DynamicLibrary.load_library': Could not open library 'vips.so.42': vips.so.42: cannot open shared object file: No such file or directory. (LoadError) Could not open library 'libvips.so.42': libvips.so.42: cannot open shared object file: No such file or directory. Searched in from /home/zzak/.rbenv/versions/3.5.0/lib/ruby/gems/3.5.0+4/gems/ffi-1.17.2/lib/ffi/library.rb:95:in 'block in FFI::Library#ffi_lib' from /home/zzak/.rbenv/versions/3.5.0/lib/ruby/gems/3.5.0+4/gems/ffi-1.17.2/lib/ffi/library.rb:94:in 'Array#map' from /home/zzak/.rbenv/versions/3.5.0/lib/ruby/gems/3.5.0+4/gems/ffi-1.17.2/lib/ffi/library.rb:94:in 'FFI::Library#ffi_lib' from /home/zzak/.rbenv/versions/3.5.0/lib/ruby/gems/3.5.0+4/gems/ruby-vips-2.2.5/lib/vips.rb:46:in '' from /home/zzak/.rbenv/versions/3.5.0/lib/ruby/gems/3.5.0+4/gems/ruby-vips-2.2.5/lib/vips.rb:43:in '' ``` However, because this happens when loading the engine, I think the error was swallowed by Rails startup, and all of the other config setup at the start of loading the engine was not evaluated, so parts like: ``` config.active_storage.paths = ActiveSupport::OrderedOptions.new config.active_storage.queues = ActiveSupport::InheritableOptions.new ``` Which meant when booting a new Rails app without libvips installed on the system, you actually got this error instead: ``` NoMethodError: undefined method 'analysis=' for nil (NoMethodError) active_storage.queues.analysis = :active_storage_analysis ``` If you did manage to boot the app, you could see the other defaults were not setup yet. ``` $ bin/rails c Loading development environment (Rails 8.1.0.beta1) >> ActiveStorage.variable_content_types => [] ``` --- .../lib/active_storage/analyzer/image_analyzer/vips.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/activestorage/lib/active_storage/analyzer/image_analyzer/vips.rb b/activestorage/lib/active_storage/analyzer/image_analyzer/vips.rb index 68a9f87f7d7ad..ca2ead77ea076 100644 --- a/activestorage/lib/active_storage/analyzer/image_analyzer/vips.rb +++ b/activestorage/lib/active_storage/analyzer/image_analyzer/vips.rb @@ -12,7 +12,7 @@ gem "ruby-vips" require "ruby-vips" rescue LoadError => error - raise error unless error.message.include?("ruby-vips") + raise error unless error.message.match?(/libvips|ruby-vips/) end module ActiveStorage From 6faca022db3f518c7e0d70433fc4c8a17dc8745a Mon Sep 17 00:00:00 2001 From: Earlopain <14981592+Earlopain@users.noreply.github.com> Date: Sat, 20 Sep 2025 15:37:07 +0200 Subject: [PATCH 0646/1075] Restore support to analyze with `image_magick` Since https://github.com/rails/rails/commit/93c00a8c74c24a3da40d30b6d0c3c667b8ebe9ba, `config.active_storage.analyzers` contains both vips and image magick analyzers. However, they both inherit from `Analyzer::ImageAnalyzer`, which means they just check for `blob.image?` to check if they should accept the image. That means that even if the user explicitly `variant_processor = :mini_magick`, vips gets called first (because it comes first in the array), and image magick never gets a chance to do anything. Before that commit it worked because the analyzers were conditionally added to the array, so there was only ever one or the other present. This fixes this by once again implementing custom `accept?`, which was previously removed in https://github.com/rails/rails/commit/e978ef011f11102771c5b302f25d9ab7895316cb --- .../analyzer/image_analyzer/image_magick.rb | 4 ++++ .../analyzer/image_analyzer/vips.rb | 4 ++++ .../analyzer/image_analyzer/image_magick_test.rb | 16 ++++++++++++++-- .../test/analyzer/image_analyzer/vips_test.rb | 4 ++-- 4 files changed, 24 insertions(+), 4 deletions(-) diff --git a/activestorage/lib/active_storage/analyzer/image_analyzer/image_magick.rb b/activestorage/lib/active_storage/analyzer/image_analyzer/image_magick.rb index 65c843415f1e9..e15629a504f6d 100644 --- a/activestorage/lib/active_storage/analyzer/image_analyzer/image_magick.rb +++ b/activestorage/lib/active_storage/analyzer/image_analyzer/image_magick.rb @@ -11,6 +11,10 @@ module ActiveStorage # This analyzer relies on the third-party {MiniMagick}[https://github.com/minimagick/minimagick] gem. MiniMagick requires # the {ImageMagick}[http://www.imagemagick.org] system library. class Analyzer::ImageAnalyzer::ImageMagick < Analyzer::ImageAnalyzer + def self.accept?(blob) + super && ActiveStorage.variant_processor == :mini_magick + end + private def read_image download_blob_to_tempfile do |file| diff --git a/activestorage/lib/active_storage/analyzer/image_analyzer/vips.rb b/activestorage/lib/active_storage/analyzer/image_analyzer/vips.rb index ca2ead77ea076..8f86c26e25f3e 100644 --- a/activestorage/lib/active_storage/analyzer/image_analyzer/vips.rb +++ b/activestorage/lib/active_storage/analyzer/image_analyzer/vips.rb @@ -19,6 +19,10 @@ module ActiveStorage # This analyzer relies on the third-party {ruby-vips}[https://github.com/libvips/ruby-vips] gem. Ruby-vips requires # the {libvips}[https://libvips.github.io/libvips/] system library. class Analyzer::ImageAnalyzer::Vips < Analyzer::ImageAnalyzer + def self.accept?(blob) + super && ActiveStorage.variant_processor == :vips + end + private def read_image download_blob_to_tempfile do |file| diff --git a/activestorage/test/analyzer/image_analyzer/image_magick_test.rb b/activestorage/test/analyzer/image_analyzer/image_magick_test.rb index 5b78a8e7a375f..bc80288f91bd3 100644 --- a/activestorage/test/analyzer/image_analyzer/image_magick_test.rb +++ b/activestorage/test/analyzer/image_analyzer/image_magick_test.rb @@ -46,6 +46,18 @@ class ActiveStorage::Analyzer::ImageAnalyzer::ImageMagickTest < ActiveSupport::T end end + test "analyzing with ruby-vips unavailable" do + stub_const(Object, :Vips, Module.new) do + analyze_with_image_magick do + blob = create_file_blob(filename: "racecar.jpg", content_type: "image/jpeg") + metadata = extract_metadata_from(blob) + + assert_equal 4104, metadata[:width] + assert_equal 2736, metadata[:height] + end + end + end + test "instrumenting analysis" do blob = create_file_blob(filename: "racecar.jpg", content_type: "image/jpeg") @@ -60,12 +72,12 @@ class ActiveStorage::Analyzer::ImageAnalyzer::ImageMagickTest < ActiveSupport::T private def analyze_with_image_magick - previous_analyzers, ActiveStorage.analyzers = ActiveStorage.analyzers, [ActiveStorage::Analyzer::ImageAnalyzer::ImageMagick] + previous_processor, ActiveStorage.variant_processor = ActiveStorage.variant_processor, :mini_magick yield rescue LoadError ENV["BUILDKITE"] ? raise : skip("Variant processor image_magick is not installed") ensure - ActiveStorage.analyzers = previous_analyzers + ActiveStorage.variant_processor = previous_processor end end diff --git a/activestorage/test/analyzer/image_analyzer/vips_test.rb b/activestorage/test/analyzer/image_analyzer/vips_test.rb index 5c4387d2d11bb..446986bd6929f 100644 --- a/activestorage/test/analyzer/image_analyzer/vips_test.rb +++ b/activestorage/test/analyzer/image_analyzer/vips_test.rb @@ -60,12 +60,12 @@ class ActiveStorage::Analyzer::ImageAnalyzer::VipsTest < ActiveSupport::TestCase private def analyze_with_vips - previous_analyzers, ActiveStorage.analyzers = ActiveStorage.analyzers, [ActiveStorage::Analyzer::ImageAnalyzer::Vips] + previous_processor, ActiveStorage.variant_processor = ActiveStorage.variant_processor, :vips yield rescue LoadError ENV["BUILDKITE"] ? raise : skip("Variant processor vips is not installed") ensure - ActiveStorage.analyzers = previous_analyzers + ActiveStorage.variant_processor = previous_processor end end From cd49fa3e8058d4d37112abcf5eb6d85a9bfa4137 Mon Sep 17 00:00:00 2001 From: Hartley McGuire Date: Sat, 31 Aug 2024 21:27:35 -0400 Subject: [PATCH 0647/1075] Fix label `for` attribute missing form namespace Previously, when a form includes a `namespace` and a label inside that form has an explicit `for`, the rendered `for` attribute would not be prefixed with the `namespace`. The problem is that the label tag uses `add_default_name_and_id`, which operates on the `id` option of the hash passed in. However, since the label tag needs the `for` value set, `render` does a lot of extra work to move the value from `id` to `for`, _sometimes_. In this case, we could fix the condition for copying `id` to `for`, however I think a better solution is to change `add_default_name_and_id` to `add_default_name_and_field` and allow the field being set to be configured. This simplifies the label's `render` by removing the need to duplicate options and conditionally copy `id` into `for` at all. Co-authored-by: abeidahmed --- .../app/helpers/action_text/tag_helper.rb | 2 +- actionview/CHANGELOG.md | 4 ++++ .../lib/action_view/helpers/tags/base.rb | 18 +++++++++--------- .../lib/action_view/helpers/tags/check_box.rb | 4 ++-- .../lib/action_view/helpers/tags/file_field.rb | 2 +- .../lib/action_view/helpers/tags/label.rb | 13 +++---------- .../action_view/helpers/tags/radio_button.rb | 2 +- .../helpers/tags/select_renderer.rb | 2 +- .../lib/action_view/helpers/tags/text_area.rb | 2 +- .../lib/action_view/helpers/tags/text_field.rb | 2 +- .../template/form_helper/form_with_test.rb | 14 ++++++++++++++ actionview/test/template/form_helper_test.rb | 14 ++++++++++++++ 12 files changed, 52 insertions(+), 27 deletions(-) diff --git a/actiontext/app/helpers/action_text/tag_helper.rb b/actiontext/app/helpers/action_text/tag_helper.rb index ecd937dadb861..375eafcf9b01e 100644 --- a/actiontext/app/helpers/action_text/tag_helper.rb +++ b/actiontext/app/helpers/action_text/tag_helper.rb @@ -55,7 +55,7 @@ class Tags::ActionText < Tags::Base def render options = @options.stringify_keys - add_default_name_and_id(options) + add_default_name_and_field(options) options["input"] ||= dom_id(object, [options["id"], :trix_input].compact.join("_")) if object html_tag = @template_object.rich_textarea_tag(options.delete("name"), options.fetch("value") { value }, options.except("value")) error_wrapping(html_tag) diff --git a/actionview/CHANGELOG.md b/actionview/CHANGELOG.md index 10f617fa40161..ece25269dbc30 100644 --- a/actionview/CHANGELOG.md +++ b/actionview/CHANGELOG.md @@ -1,3 +1,7 @@ +* Fix label with `for` option not getting prefixed by form `namespace` value + + *Abeid Ahmed*, *Hartley McGuire* + * Add `fetchpriority` to Link headers to match HTML generated by `preload_link_tag`. *Guillermo Iguaran* diff --git a/actionview/lib/action_view/helpers/tags/base.rb b/actionview/lib/action_view/helpers/tags/base.rb index 02225aad2de22..8917f80c13663 100644 --- a/actionview/lib/action_view/helpers/tags/base.rb +++ b/actionview/lib/action_view/helpers/tags/base.rb @@ -80,27 +80,27 @@ def retrieve_autoindex(pre_match) end end - def add_default_name_and_id_for_value(tag_value, options) + def add_default_name_and_field_for_value(tag_value, options, field = "id") if tag_value.nil? - add_default_name_and_id(options) + add_default_name_and_field(options, field) else - specified_id = options["id"] - add_default_name_and_id(options) + specified_field = options[field] + add_default_name_and_field(options, field) - if specified_id.blank? && options["id"].present? - options["id"] += "_#{sanitized_value(tag_value)}" + if specified_field.blank? && options[field].present? + options[field] += "_#{sanitized_value(tag_value)}" end end end - def add_default_name_and_id(options) + def add_default_name_and_field(options, field = "id") index = name_and_id_index(options) options["name"] = options.fetch("name") { tag_name(options["multiple"], index) } if generate_ids? - options["id"] = options.fetch("id") { tag_id(index, options.delete("namespace")) } + options[field] = options.fetch(field) { tag_id(index, options.delete("namespace")) } if namespace = options.delete("namespace") - options["id"] = options["id"] ? "#{namespace}_#{options['id']}" : namespace + options[field] = options[field] ? "#{namespace}_#{options[field]}" : namespace end end end diff --git a/actionview/lib/action_view/helpers/tags/check_box.rb b/actionview/lib/action_view/helpers/tags/check_box.rb index 86ccaf29d062e..3976c23d98575 100644 --- a/actionview/lib/action_view/helpers/tags/check_box.rb +++ b/actionview/lib/action_view/helpers/tags/check_box.rb @@ -21,10 +21,10 @@ def render options["checked"] = "checked" if input_checked?(options) if options["multiple"] - add_default_name_and_id_for_value(@checked_value, options) + add_default_name_and_field_for_value(@checked_value, options) options.delete("multiple") else - add_default_name_and_id(options) + add_default_name_and_field(options) end include_hidden = options.delete("include_hidden") { true } diff --git a/actionview/lib/action_view/helpers/tags/file_field.rb b/actionview/lib/action_view/helpers/tags/file_field.rb index adefc0bda5e4c..d34def5c01f51 100644 --- a/actionview/lib/action_view/helpers/tags/file_field.rb +++ b/actionview/lib/action_view/helpers/tags/file_field.rb @@ -7,7 +7,7 @@ class FileField < TextField # :nodoc: def render include_hidden = @options.delete(:include_hidden) options = @options.stringify_keys - add_default_name_and_id(options) + add_default_name_and_field(options) if options["multiple"] && include_hidden hidden_field_for_multiple_file(options) + super diff --git a/actionview/lib/action_view/helpers/tags/label.rb b/actionview/lib/action_view/helpers/tags/label.rb index 157fca057ebfa..c1c8c8efe381f 100644 --- a/actionview/lib/action_view/helpers/tags/label.rb +++ b/actionview/lib/action_view/helpers/tags/label.rb @@ -48,18 +48,11 @@ def initialize(object_name, method_name, template_object, content_or_options = n def render(&block) options = @options.stringify_keys tag_value = options.delete("value") - name_and_id = options.dup - if name_and_id["for"] - name_and_id["id"] = name_and_id["for"] - else - name_and_id.delete("id") - end - - add_default_name_and_id_for_value(tag_value, name_and_id) + add_default_name_and_field_for_value(tag_value, options, "for") options.delete("index") + options.delete("name") options.delete("namespace") - options["for"] = name_and_id["id"] unless options.key?("for") builder = LabelBuilder.new(@template_object, @object_name, @method_name, @object, tag_value) @@ -71,7 +64,7 @@ def render(&block) render_component(builder) end - label_tag(name_and_id["id"], content, options) + label_tag(options["for"], content, options) end private diff --git a/actionview/lib/action_view/helpers/tags/radio_button.rb b/actionview/lib/action_view/helpers/tags/radio_button.rb index 4ce6c9f6bcf77..aafec1e086d39 100644 --- a/actionview/lib/action_view/helpers/tags/radio_button.rb +++ b/actionview/lib/action_view/helpers/tags/radio_button.rb @@ -18,7 +18,7 @@ def render options["type"] = "radio" options["value"] = @tag_value options["checked"] = "checked" if input_checked?(options) - add_default_name_and_id_for_value(@tag_value, options) + add_default_name_and_field_for_value(@tag_value, options) tag("input", options) end diff --git a/actionview/lib/action_view/helpers/tags/select_renderer.rb b/actionview/lib/action_view/helpers/tags/select_renderer.rb index 9cf7fce68b4c8..bfa09f2bed3bb 100644 --- a/actionview/lib/action_view/helpers/tags/select_renderer.rb +++ b/actionview/lib/action_view/helpers/tags/select_renderer.rb @@ -11,7 +11,7 @@ def select_content_tag(option_tags, options, html_options) html_options[prop.to_s] = options.delete(prop) if options.key?(prop) && !html_options.key?(prop.to_s) end - add_default_name_and_id(html_options) + add_default_name_and_field(html_options) if placeholder_required?(html_options) raise ArgumentError, "include_blank cannot be false for a required field." if options[:include_blank] == false diff --git a/actionview/lib/action_view/helpers/tags/text_area.rb b/actionview/lib/action_view/helpers/tags/text_area.rb index 4519082ff6faa..75a8510d4ca79 100644 --- a/actionview/lib/action_view/helpers/tags/text_area.rb +++ b/actionview/lib/action_view/helpers/tags/text_area.rb @@ -10,7 +10,7 @@ class TextArea < Base # :nodoc: def render options = @options.stringify_keys - add_default_name_and_id(options) + add_default_name_and_field(options) if size = options.delete("size") options["cols"], options["rows"] = size.split("x") if size.respond_to?(:split) diff --git a/actionview/lib/action_view/helpers/tags/text_field.rb b/actionview/lib/action_view/helpers/tags/text_field.rb index c579e9e79fa29..9b56d1564eed2 100644 --- a/actionview/lib/action_view/helpers/tags/text_field.rb +++ b/actionview/lib/action_view/helpers/tags/text_field.rb @@ -13,7 +13,7 @@ def render options["size"] = options["maxlength"] unless options.key?("size") options["type"] ||= field_type options["value"] = options.fetch("value") { value_before_type_cast } unless field_type == "file" - add_default_name_and_id(options) + add_default_name_and_field(options) tag("input", options) end diff --git a/actionview/test/template/form_helper/form_with_test.rb b/actionview/test/template/form_helper/form_with_test.rb index e9531fd0e964a..0fdb3c390bcc9 100644 --- a/actionview/test/template/form_helper/form_with_test.rb +++ b/actionview/test/template/form_helper/form_with_test.rb @@ -1051,6 +1051,20 @@ def test_form_with_label_accesses_object_through_label_tag_builder assert_dom_equal expected, @rendered end + def test_form_with_label_namespace + form_with(model: Post.new, namespace: "namespace") do |f| + concat f.label(:title, for: "my_title") + concat f.text_field(:title, id: "my_title") + end + + expected = whole_form("/posts") do + "" \ + "" + end + + assert_dom_equal expected, @rendered + end + def test_form_with_label_error_wrapping form_with(model: @post) do |f| concat f.label(:author_name, class: "label") diff --git a/actionview/test/template/form_helper_test.rb b/actionview/test/template/form_helper_test.rb index d1d325dd97d57..3afe0e3138849 100644 --- a/actionview/test/template/form_helper_test.rb +++ b/actionview/test/template/form_helper_test.rb @@ -2653,6 +2653,20 @@ def test_form_for_with_namespace_with_label assert_dom_equal expected, @rendered end + def test_form_for_with_namespace_with_custom_label_for + form_for(@post, namespace: "namespace") do |f| + concat f.label(:title, for: "my_title") + concat f.text_field(:title, id: "my_title") + end + + expected = whole_form("/posts/123", "namespace_edit_post_123", "edit_post", method: "patch") do + "" \ + "" + end + + assert_dom_equal expected, @rendered + end + def test_form_for_with_namespace_and_as_option form_for(@post, namespace: "namespace", as: "custom_name") do |f| concat f.text_field(:title) From f25ee7465c13cb396792c3b80c9eff45ef9a2343 Mon Sep 17 00:00:00 2001 From: zzak Date: Sun, 21 Sep 2025 16:04:25 +0900 Subject: [PATCH 0648/1075] Alphabetical frameworks for AS Instrumentation guide [ci skip] --- .../source/active_support_instrumentation.md | 556 +++++++++--------- 1 file changed, 278 insertions(+), 278 deletions(-) diff --git a/guides/source/active_support_instrumentation.md b/guides/source/active_support_instrumentation.md index fb5d8a65d5a8f..f483b344327b7 100644 --- a/guides/source/active_support_instrumentation.md +++ b/guides/source/active_support_instrumentation.md @@ -89,6 +89,44 @@ Rails Framework Hooks Within the Ruby on Rails framework, there are a number of hooks provided for common events. These events and their payloads are detailed below. +### Action Cable + +#### `perform_action.action_cable` + +| Key | Value | +| ---------------- | ------------------------- | +| `:channel_class` | Name of the channel class | +| `:action` | The action | +| `:data` | A hash of data | + +#### `transmit.action_cable` + +| Key | Value | +| ---------------- | ------------------------- | +| `:channel_class` | Name of the channel class | +| `:data` | A hash of data | +| `:via` | Via | + +#### `transmit_subscription_confirmation.action_cable` + +| Key | Value | +| ---------------- | ------------------------- | +| `:channel_class` | Name of the channel class | + +#### `transmit_subscription_rejection.action_cable` + +| Key | Value | +| ---------------- | ------------------------- | +| `:channel_class` | Name of the channel class | + +#### `broadcast.action_cable` + +| Key | Value | +| --------------- | -------------------- | +| `:broadcasting` | A named broadcasting | +| `:message` | A hash of message | +| `:coder` | The coder | + ### Action Controller #### `start_processing.action_controller` @@ -298,6 +336,77 @@ Additional keys may be added by the caller. | ----------- | ---------------------------------------- | | `:request` | The [`ActionDispatch::Request`][] object | +[`ActionDispatch::Request`]: https://api.rubyonrails.org/classes/ActionDispatch/Request.html +[`ActionDispatch::Response`]: https://api.rubyonrails.org/classes/ActionDispatch/Response.html + +### Action Mailbox + +#### `process.action_mailbox` + +| Key | Value | +| -----------------| ------------------------------------------------------ | +| `:mailbox` | Instance of the Mailbox class inheriting from [`ActionMailbox::Base`][] | +| `:inbound_email` | Hash with data about the inbound email being processed | + +```ruby +{ + mailbox: #, + inbound_email: { + id: 1, + message_id: "0CB459E0-0336-41DA-BC88-E6E28C697DDB@37signals.com", + status: "processing" + } +} +``` + +[`ActionMailbox::Base`]: https://api.rubyonrails.org/classes/ActionMailbox/Base.html + +### Action Mailer + +#### `deliver.action_mailer` + +| Key | Value | +| --------------------- | ---------------------------------------------------- | +| `:mailer` | Name of the mailer class | +| `:message_id` | ID of the message, generated by the Mail gem | +| `:subject` | Subject of the mail | +| `:to` | To address(es) of the mail | +| `:from` | From address of the mail | +| `:bcc` | BCC addresses of the mail | +| `:cc` | CC addresses of the mail | +| `:date` | Date of the mail | +| `:mail` | The encoded form of the mail | +| `:perform_deliveries` | Whether delivery of this message is performed or not | + +```ruby +{ + mailer: "Notification", + message_id: "4f5b5491f1774_181b23fc3d4434d38138e5@mba.local.mail", + subject: "Rails Guides", + to: ["users@rails.com", "dhh@rails.com"], + from: ["me@rails.com"], + date: Sat, 10 Mar 2012 14:18:09 +0100, + mail: "...", # omitted for brevity + perform_deliveries: true +} +``` + +#### `process.action_mailer` + +| Key | Value | +| ------------- | ------------------------ | +| `:mailer` | Name of the mailer class | +| `:action` | The action | +| `:args` | The arguments | + +```ruby +{ + mailer: "Notification", + action: "welcome_email", + args: [] +} +``` + ### Action View #### `render_template.action_view` @@ -361,8 +470,68 @@ The `:cache_hits` key is only included if the collection is rendered with `cache } ``` -[`ActionDispatch::Request`]: https://api.rubyonrails.org/classes/ActionDispatch/Request.html -[`ActionDispatch::Response`]: https://api.rubyonrails.org/classes/ActionDispatch/Response.html +### Active Job + +#### `enqueue_at.active_job` + +| Key | Value | +| ------------ | -------------------------------------- | +| `:adapter` | QueueAdapter object processing the job | +| `:job` | Job object | + +#### `enqueue.active_job` + +| Key | Value | +| ------------ | -------------------------------------- | +| `:adapter` | QueueAdapter object processing the job | +| `:job` | Job object | + +#### `enqueue_retry.active_job` + +| Key | Value | +| ------------ | -------------------------------------- | +| `:job` | Job object | +| `:adapter` | QueueAdapter object processing the job | +| `:error` | The error that caused the retry | +| `:wait` | The delay of the retry | + +#### `enqueue_all.active_job` + +| Key | Value | +| ------------ | -------------------------------------- | +| `:adapter` | QueueAdapter object processing the job | +| `:jobs` | An array of Job objects | + +#### `perform_start.active_job` + +| Key | Value | +| ------------ | -------------------------------------- | +| `:adapter` | QueueAdapter object processing the job | +| `:job` | Job object | + +#### `perform.active_job` + +| Key | Value | +| ------------- | --------------------------------------------- | +| `:adapter` | QueueAdapter object processing the job | +| `:job` | Job object | +| `:db_runtime` | Amount spent executing database queries in ms | + +#### `retry_stopped.active_job` + +| Key | Value | +| ------------ | -------------------------------------- | +| `:adapter` | QueueAdapter object processing the job | +| `:job` | Job object | +| `:error` | The error that caused the retry | + +#### `discard.active_job` + +| Key | Value | +| ------------ | -------------------------------------- | +| `:adapter` | QueueAdapter object processing the job | +| `:job` | Job object | +| `:error` | The error that caused the discard | ### Active Record @@ -504,75 +673,118 @@ configured deprecated associations mode is `:notify`. The `:location` is a `Thread::Backtrace::Location` object, and `:backtrace`, if present, is an array of `Thread::Backtrace::Location` objects. These are computed using the Active Record backtrace cleaner. In Rails applications, this -is the same as `Rails.backtrace_cleaner. +is the same as `Rails.backtrace_cleaner`. -### Action Mailer +### Active Storage -#### `deliver.action_mailer` +#### `preview.active_storage` -| Key | Value | -| --------------------- | ---------------------------------------------------- | -| `:mailer` | Name of the mailer class | -| `:message_id` | ID of the message, generated by the Mail gem | -| `:subject` | Subject of the mail | -| `:to` | To address(es) of the mail | -| `:from` | From address of the mail | -| `:bcc` | BCC addresses of the mail | -| `:cc` | CC addresses of the mail | -| `:date` | Date of the mail | -| `:mail` | The encoded form of the mail | -| `:perform_deliveries` | Whether delivery of this message is performed or not | +| Key | Value | +| ------------ | ------------------- | +| `:key` | Secure token | -```ruby -{ - mailer: "Notification", - message_id: "4f5b5491f1774_181b23fc3d4434d38138e5@mba.local.mail", - subject: "Rails Guides", - to: ["users@rails.com", "dhh@rails.com"], - from: ["me@rails.com"], - date: Sat, 10 Mar 2012 14:18:09 +0100, - mail: "...", # omitted for brevity - perform_deliveries: true -} -``` +#### `transform.active_storage` -#### `process.action_mailer` +#### `analyze.active_storage` -| Key | Value | -| ------------- | ------------------------ | -| `:mailer` | Name of the mailer class | -| `:action` | The action | -| `:args` | The arguments | +| Key | Value | +| ------------ | ------------------------------ | +| `:analyzer` | Name of analyzer e.g., ffprobe | -```ruby -{ - mailer: "Notification", - action: "welcome_email", - args: [] -} -``` +### Active Storage: Storage Service -### Active Support: Caching +#### `service_upload.active_storage` -#### `cache_read.active_support` +| Key | Value | +| ------------ | ---------------------------- | +| `:key` | Secure token | +| `:service` | Name of the service | +| `:checksum` | Checksum to ensure integrity | -| Key | Value | -| ------------------ | ----------------------- | -| `:key` | Key used in the store | -| `:store` | Name of the store class | -| `:hit` | If this read is a hit | -| `:super_operation` | `:fetch` if a read is done with [`fetch`][ActiveSupport::Cache::Store#fetch] | +#### `service_streaming_download.active_storage` -#### `cache_read_multi.active_support` +| Key | Value | +| ------------ | ------------------- | +| `:key` | Secure token | +| `:service` | Name of the service | -| Key | Value | -| ------------------ | ----------------------- | -| `:key` | Keys used in the store | -| `:store` | Name of the store class | -| `:hits` | Keys of cache hits | -| `:super_operation` | `:fetch_multi` if a read is done with [`fetch_multi`][ActiveSupport::Cache::Store#fetch_multi] | +#### `service_download_chunk.active_storage` -#### `cache_generate.active_support` +| Key | Value | +| ------------ | ------------------------------- | +| `:key` | Secure token | +| `:service` | Name of the service | +| `:range` | Byte range attempted to be read | + +#### `service_download.active_storage` + +| Key | Value | +| ------------ | ------------------- | +| `:key` | Secure token | +| `:service` | Name of the service | + +#### `service_delete.active_storage` + +| Key | Value | +| ------------ | ------------------- | +| `:key` | Secure token | +| `:service` | Name of the service | + +#### `service_delete_prefixed.active_storage` + +| Key | Value | +| ------------ | ------------------- | +| `:prefix` | Key prefix | +| `:service` | Name of the service | + +#### `service_exist.active_storage` + +| Key | Value | +| ------------ | --------------------------- | +| `:key` | Secure token | +| `:service` | Name of the service | +| `:exist` | File or blob exists or not | + +#### `service_url.active_storage` + +| Key | Value | +| ------------ | ------------------- | +| `:key` | Secure token | +| `:service` | Name of the service | +| `:url` | Generated URL | + +#### `service_update_metadata.active_storage` + +This event is only emitted when using the Google Cloud Storage service. + +| Key | Value | +| --------------- | -------------------------------- | +| `:key` | Secure token | +| `:service` | Name of the service | +| `:content_type` | HTTP `Content-Type` field | +| `:disposition` | HTTP `Content-Disposition` field | + +### Active Support: Caching + +#### `cache_read.active_support` + +| Key | Value | +| ------------------ | ----------------------- | +| `:key` | Key used in the store | +| `:store` | Name of the store class | +| `:hit` | If this read is a hit | +| `:super_operation` | `:fetch` if a read is done with [`fetch`][ActiveSupport::Cache::Store#fetch] | + +#### `cache_read_multi.active_support` + +| Key | Value | +| ------------------ | ----------------------- | +| `:key` | Keys used in the store | +| `:store` | Name of the store class | +| `:hits` | Keys of cache hits | +| `:super_operation` | `:fetch_multi` if a read is done with [`fetch_multi`][ActiveSupport::Cache::Store#fetch_multi] | + +#### `cache_generate.active_support` This event is only emitted when [`fetch`][ActiveSupport::Cache::Store#fetch] is called with a block. @@ -777,226 +989,6 @@ This event is only emitted when using [`MemoryStore`][ActiveSupport::Cache::Memo } ``` -### Active Job - -#### `enqueue_at.active_job` - -| Key | Value | -| ------------ | -------------------------------------- | -| `:adapter` | QueueAdapter object processing the job | -| `:job` | Job object | - -#### `enqueue.active_job` - -| Key | Value | -| ------------ | -------------------------------------- | -| `:adapter` | QueueAdapter object processing the job | -| `:job` | Job object | - -#### `enqueue_retry.active_job` - -| Key | Value | -| ------------ | -------------------------------------- | -| `:job` | Job object | -| `:adapter` | QueueAdapter object processing the job | -| `:error` | The error that caused the retry | -| `:wait` | The delay of the retry | - -#### `enqueue_all.active_job` - -| Key | Value | -| ------------ | -------------------------------------- | -| `:adapter` | QueueAdapter object processing the job | -| `:jobs` | An array of Job objects | - -#### `perform_start.active_job` - -| Key | Value | -| ------------ | -------------------------------------- | -| `:adapter` | QueueAdapter object processing the job | -| `:job` | Job object | - -#### `perform.active_job` - -| Key | Value | -| ------------- | --------------------------------------------- | -| `:adapter` | QueueAdapter object processing the job | -| `:job` | Job object | -| `:db_runtime` | Amount spent executing database queries in ms | - -#### `retry_stopped.active_job` - -| Key | Value | -| ------------ | -------------------------------------- | -| `:adapter` | QueueAdapter object processing the job | -| `:job` | Job object | -| `:error` | The error that caused the retry | - -#### `discard.active_job` - -| Key | Value | -| ------------ | -------------------------------------- | -| `:adapter` | QueueAdapter object processing the job | -| `:job` | Job object | -| `:error` | The error that caused the discard | - -### Action Cable - -#### `perform_action.action_cable` - -| Key | Value | -| ---------------- | ------------------------- | -| `:channel_class` | Name of the channel class | -| `:action` | The action | -| `:data` | A hash of data | - -#### `transmit.action_cable` - -| Key | Value | -| ---------------- | ------------------------- | -| `:channel_class` | Name of the channel class | -| `:data` | A hash of data | -| `:via` | Via | - -#### `transmit_subscription_confirmation.action_cable` - -| Key | Value | -| ---------------- | ------------------------- | -| `:channel_class` | Name of the channel class | - -#### `transmit_subscription_rejection.action_cable` - -| Key | Value | -| ---------------- | ------------------------- | -| `:channel_class` | Name of the channel class | - -#### `broadcast.action_cable` - -| Key | Value | -| --------------- | -------------------- | -| `:broadcasting` | A named broadcasting | -| `:message` | A hash of message | -| `:coder` | The coder | - -### Active Storage - -#### `preview.active_storage` - -| Key | Value | -| ------------ | ------------------- | -| `:key` | Secure token | - -#### `transform.active_storage` - -#### `analyze.active_storage` - -| Key | Value | -| ------------ | ------------------------------ | -| `:analyzer` | Name of analyzer e.g., ffprobe | - -### Active Storage: Storage Service - -#### `service_upload.active_storage` - -| Key | Value | -| ------------ | ---------------------------- | -| `:key` | Secure token | -| `:service` | Name of the service | -| `:checksum` | Checksum to ensure integrity | - -#### `service_streaming_download.active_storage` - -| Key | Value | -| ------------ | ------------------- | -| `:key` | Secure token | -| `:service` | Name of the service | - -#### `service_download_chunk.active_storage` - -| Key | Value | -| ------------ | ------------------------------- | -| `:key` | Secure token | -| `:service` | Name of the service | -| `:range` | Byte range attempted to be read | - -#### `service_download.active_storage` - -| Key | Value | -| ------------ | ------------------- | -| `:key` | Secure token | -| `:service` | Name of the service | - -#### `service_delete.active_storage` - -| Key | Value | -| ------------ | ------------------- | -| `:key` | Secure token | -| `:service` | Name of the service | - -#### `service_delete_prefixed.active_storage` - -| Key | Value | -| ------------ | ------------------- | -| `:prefix` | Key prefix | -| `:service` | Name of the service | - -#### `service_exist.active_storage` - -| Key | Value | -| ------------ | --------------------------- | -| `:key` | Secure token | -| `:service` | Name of the service | -| `:exist` | File or blob exists or not | - -#### `service_url.active_storage` - -| Key | Value | -| ------------ | ------------------- | -| `:key` | Secure token | -| `:service` | Name of the service | -| `:url` | Generated URL | - -#### `service_update_metadata.active_storage` - -This event is only emitted when using the Google Cloud Storage service. - -| Key | Value | -| --------------- | -------------------------------- | -| `:key` | Secure token | -| `:service` | Name of the service | -| `:content_type` | HTTP `Content-Type` field | -| `:disposition` | HTTP `Content-Disposition` field | - -### Action Mailbox - -#### `process.action_mailbox` - -| Key | Value | -| -----------------| ------------------------------------------------------ | -| `:mailbox` | Instance of the Mailbox class inheriting from [`ActionMailbox::Base`][] | -| `:inbound_email` | Hash with data about the inbound email being processed | - -```ruby -{ - mailbox: #, - inbound_email: { - id: 1, - message_id: "0CB459E0-0336-41DA-BC88-E6E28C697DDB@37signals.com", - status: "processing" - } -} -``` - -[`ActionMailbox::Base`]: https://api.rubyonrails.org/classes/ActionMailbox/Base.html - -### Railties - -#### `load_config_initializer.railties` - -| Key | Value | -| -------------- | --------------------------------------------------- | -| `:initializer` | Path of loaded initializer in `config/initializers` | - ### Rails #### `deprecation.rails` @@ -1008,6 +1000,14 @@ This event is only emitted when using the Google Cloud Storage service. | `:gem_name` | Name of the gem reporting the deprecation | | `:deprecation_horizon` | Version where the deprecated behavior will be removed | +### Railties + +#### `load_config_initializer.railties` + +| Key | Value | +| -------------- | --------------------------------------------------- | +| `:initializer` | Path of loaded initializer in `config/initializers` | + Exceptions ---------- From 8626c759ccc44c1045b9b9bff6877fbc98f993e1 Mon Sep 17 00:00:00 2001 From: Koji NAKAMURA Date: Sun, 21 Sep 2025 14:15:34 +0900 Subject: [PATCH 0649/1075] Fix lease_connection to preserve pool state when checkout callbacks fail Moves the sticky flag assignment after checkout to ensure proper state management when checkout callbacks raise exceptions. This prevents permanent_lease? and active_connection? from being affected by callback errors. Previously, if a checkout callback raised an exception, the lease would be marked as sticky before the connection was actually established, leading to inconsistent pool state. --- .../abstract/connection_pool.rb | 3 ++- .../test/cases/connection_pool_test.rb | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb index 1a9f3c3d0b726..a68585fca8b20 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb @@ -344,8 +344,9 @@ def activated? # held in a cache keyed by a thread. def lease_connection lease = connection_lease - lease.sticky = true lease.connection ||= checkout + lease.sticky = true + lease.connection end def permanent_lease? # :nodoc: diff --git a/activerecord/test/cases/connection_pool_test.rb b/activerecord/test/cases/connection_pool_test.rb index e31e2a46424f9..09efacd18fb1c 100644 --- a/activerecord/test/cases/connection_pool_test.rb +++ b/activerecord/test/cases/connection_pool_test.rb @@ -1477,6 +1477,25 @@ def test_inspect_does_not_show_secrets pool&.disconnect! end + def test_checkout_callback_error_does_not_affect_permanent_lease_and_active_connection_state + checkout_error = StandardError.new("error during checkout") + proc_to_raise = -> { raise checkout_error } + ActiveRecord::ConnectionAdapters::AbstractAdapter.set_callback(:checkout, :after, proc_to_raise) + + assert_predicate @pool, :permanent_lease? + assert_not_predicate @pool, :active_connection? + + error = assert_raises StandardError do + @pool.lease_connection + end + assert_same checkout_error, error + + assert_predicate @pool, :permanent_lease? + assert_not_predicate @pool, :active_connection? + ensure + ActiveRecord::ConnectionAdapters::AbstractAdapter.skip_callback(:checkout, :after, proc_to_raise) + end + private def active_connections(pool) pool.connections.find_all(&:in_use?) From 973e1ff0a333cc7c6409af2ac97a4a00532b15e5 Mon Sep 17 00:00:00 2001 From: fatkodima Date: Sun, 21 Sep 2025 16:05:47 +0300 Subject: [PATCH 0650/1075] Preserve selected locale when downloading mailer preview EML --- railties/lib/rails/templates/rails/mailers/email.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/railties/lib/rails/templates/rails/mailers/email.html.erb b/railties/lib/rails/templates/rails/mailers/email.html.erb index 2b91f19005271..9177e52164de2 100644 --- a/railties/lib/rails/templates/rails/mailers/email.html.erb +++ b/railties/lib/rails/templates/rails/mailers/email.html.erb @@ -156,7 +156,7 @@ <% end %>
EML File:
-
<%= link_to "Download", action: :download %>
+
<%= link_to "Download", action: :download, locale: params[:locale] %>
From 8adc277f500cb76fe0ebe0888ca2786d6ebbfe96 Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Mon, 22 Sep 2025 09:50:26 +0200 Subject: [PATCH 0651/1075] Fix javascript_include_tag `type:` option to accept symbols Fix: https://github.com/rails/rails/issues/55706 `javascript_include_tag "application", type: :module` now behaves the same as `javascript_include_tag "application", type: "module"` --- actionview/lib/action_view/helpers/asset_tag_helper.rb | 4 ++-- actionview/test/template/asset_tag_helper_test.rb | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/actionview/lib/action_view/helpers/asset_tag_helper.rb b/actionview/lib/action_view/helpers/asset_tag_helper.rb index d3061141b0bc8..e77f19fce5526 100644 --- a/actionview/lib/action_view/helpers/asset_tag_helper.rb +++ b/actionview/lib/action_view/helpers/asset_tag_helper.rb @@ -121,7 +121,7 @@ def javascript_include_tag(*sources) crossorigin = options.delete("crossorigin") crossorigin = "anonymous" if crossorigin == true integrity = options["integrity"] - rel = options["type"] == "module" ? "modulepreload" : "preload" + rel = options["type"] == "module" || options["type"] == :module ? "modulepreload" : "preload" sources_tags = sources.uniq.map { |source| href = path_to_javascript(source, path_options) @@ -370,7 +370,7 @@ def preload_link_tag(source, options = {}) integrity = options[:integrity] fetchpriority = options.delete(:fetchpriority) nopush = options.delete(:nopush) || false - rel = mime_type == "module" ? "modulepreload" : "preload" + rel = mime_type == "module" || mime_type == :module ? "modulepreload" : "preload" add_nonce = content_security_policy_nonce && respond_to?(:request) && request.content_security_policy_nonce_directives&.include?("#{as_type}-src") diff --git a/actionview/test/template/asset_tag_helper_test.rb b/actionview/test/template/asset_tag_helper_test.rb index 213be4d3fdb08..848d564aba6c9 100644 --- a/actionview/test/template/asset_tag_helper_test.rb +++ b/actionview/test/template/asset_tag_helper_test.rb @@ -337,6 +337,7 @@ def content_security_policy_nonce PreloadLinkToTag = { %(preload_link_tag '/application.js', type: 'module') => %(), + %(preload_link_tag '/application.js', type: :module) => %(), %(preload_link_tag '/styles/custom_theme.css') => %(), %(preload_link_tag '/videos/video.webm') => %(), %(preload_link_tag '/posts.json', as: 'fetch') => %(), From 2761020ddd1e9675d5f4d4573f2bf31b9decdebb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20Barri=C3=A9?= Date: Mon, 22 Sep 2025 12:08:38 +0200 Subject: [PATCH 0652/1075] Update JSONGemCoderEncoder to handle non-String hash keys Follow-up to a8a80e3396276a0c6a281f72b6776d17b2e9312b. Co-authored-by: Jean Boussier --- Gemfile.lock | 2 +- .../lib/active_support/json/encoding.rb | 17 +++++++---------- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index d4cf1c83fb68e..fa003ff29fa39 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -311,7 +311,7 @@ GEM jmespath (1.6.2) jsbundling-rails (1.3.1) railties (>= 6.0.0) - json (2.14.0) + json (2.15.0) jwt (2.10.1) base64 kamal (2.4.0) diff --git a/activesupport/lib/active_support/json/encoding.rb b/activesupport/lib/active_support/json/encoding.rb index 6facaa34f1180..6c9f340c97f4e 100644 --- a/activesupport/lib/active_support/json/encoding.rb +++ b/activesupport/lib/active_support/json/encoding.rb @@ -140,20 +140,17 @@ def stringify(jsonified) end end - if defined?(::JSON::Coder) + # ruby/json 2.14.x yields non-String keys but doesn't let us know it's a key + if defined?(::JSON::Coder) && !::JSON::VERSION.start_with?("2.14.") class JSONGemCoderEncoder # :nodoc: JSON_NATIVE_TYPES = [Hash, Array, Float, String, Symbol, Integer, NilClass, TrueClass, FalseClass, ::JSON::Fragment].freeze - CODER = ::JSON::Coder.new do |value| + CODER = ::JSON::Coder.new do |value, is_key| json_value = value.as_json + # Keep compatibility by calling to_s on non-String keys + next json_value.to_s if is_key # Handle objects returning self from as_json if json_value.equal?(value) - if JSON_NATIVE_TYPES.include?(json_value.class) - # If the callback is invoked for a native type, - # it means it is hash keys, e.g. { 1 => true } - next json_value.to_s - else - next ::JSON::Fragment.new(::JSON.generate(json_value)) - end + next ::JSON::Fragment.new(::JSON.generate(json_value)) end # Handle objects not returning JSON-native types from as_json count = 5 @@ -222,7 +219,7 @@ def encode_without_options(value) # :nodoc: self.use_standard_json_time_format = true self.escape_html_entities_in_json = true self.json_encoder = - if defined?(::JSON::Coder) + if defined?(JSONGemCoderEncoder) JSONGemCoderEncoder else JSONGemEncoder From 7898364a3879862a0802a1e3d6060eb220e49f76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20Barri=C3=A9?= Date: Mon, 22 Sep 2025 11:37:09 +0200 Subject: [PATCH 0653/1075] Add fast-path for Object#to_json(escape: false) Co-authored-by: Jean Boussier --- .../lib/active_support/json/encoding.rb | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/activesupport/lib/active_support/json/encoding.rb b/activesupport/lib/active_support/json/encoding.rb index 6c9f340c97f4e..6d7c31349384b 100644 --- a/activesupport/lib/active_support/json/encoding.rb +++ b/activesupport/lib/active_support/json/encoding.rb @@ -46,6 +46,8 @@ class << self def encode(value, options = nil) if options.nil? || options.empty? Encoding.encode_without_options(value) + elsif options == { escape: false }.freeze + Encoding.encode_without_escape(value) else Encoding.json_encoder.new(options).encode(value) end @@ -164,7 +166,14 @@ class JSONGemCoderEncoder # :nodoc: def initialize(options = nil) - @options = options ? options.dup.freeze : {}.freeze + if options + options = options.dup + @escape = options.delete(:escape) { true } + @options = options.freeze + else + @escape = true + @options = {}.freeze + end end # Encode the given object into a JSON string @@ -173,7 +182,7 @@ def encode(value) json = CODER.dump(value) - return json unless @options.fetch(:escape, true) + return json unless @escape # Rails does more escaping than the JSON gem natively does (we # escape \u2028 and \u2029 and optionally >, <, & to work around @@ -209,11 +218,16 @@ class << self def json_encoder=(encoder) @json_encoder = encoder @encoder_without_options = encoder.new + @encoder_without_escape = encoder.new(escape: false) end def encode_without_options(value) # :nodoc: @encoder_without_options.encode(value) end + + def encode_without_escape(value) # :nodoc: + @encoder_without_escape.encode(value) + end end self.use_standard_json_time_format = true From 05f8809fed978891396cbfdca02087cd05fe71e4 Mon Sep 17 00:00:00 2001 From: Yasuo Honda Date: Tue, 16 Sep 2025 22:07:02 +0900 Subject: [PATCH 0654/1075] Fix nondeterministic behavior in DeprecatedHasAndBelongsToManyAssociationsTest This commit addresses intermittent failures like: https://buildkite.com/rails/rails/builds/121865#019970b4-ac11-4f90-9c65-7e39198536a8/1295-1306 https://buildkite.com/rails/rails/builds/121622#01995272-5209-4eda-bab1-fbdce04a5762/1223-1234 ```sql Failure: DeprecatedHasAndBelongsToManyAssociationsTest#test_ [test/cases/associations/has_and_belongs_to_many_associations_test.rb:1016]: --- expected +++ actual @@ -1 +1 @@ -#, #, #, #, #]> +#, #, #, #, #]> ``` - SQL statement executed with this fix: ``` DATS::Post Load (0.2ms) SELECT "posts".* FROM "posts" INNER JOIN "categories_posts" ON "posts"."id" = "categories_posts"."post_id" WHERE "categories_posts"."category_id" = $1 ORDER BY "posts"."id" ASC [["category_id", 1]] ``` - SQL statement executed without this fix: ```sql DATS::Post Load (0.3ms) SELECT "posts".* FROM "posts" INNER JOIN "categories_posts" ON "posts"."id" = "categories_posts"."post_id" WHERE "categories_posts"."category_id" = $1 [["category_id", 1]] ``` Related to https://github.com/rails/rails/pull/55285 --- .../associations/has_and_belongs_to_many_associations_test.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 10f1893ea8808..22fc3e6671360 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 @@ -1014,7 +1014,7 @@ class DeprecatedHasAndBelongsToManyAssociationsTest < ActiveRecord::TestCase end assert_deprecated_association(:deprecated_posts, context: context_for_method(:deprecated_posts)) do - assert_equal @category.posts, @category.deprecated_posts + assert_equal @category.posts.order(:id), @category.deprecated_posts.order(:id) end end From 106d5c563621e0acc7351c3f32bc296e0c9a3256 Mon Sep 17 00:00:00 2001 From: Nony Dutton Date: Tue, 16 Sep 2025 08:26:42 +0200 Subject: [PATCH 0655/1075] Support integer shard keys Currently, `.connects_to(shards: ...)` coerces all of the shard keys into symbols. Because an integer cannot be coerced into a symbol, attempting to identify a shard with an integer will raise an exception: ```ruby 1.to_sym (irb):1:in '
': undefined method 'to_sym' for an instance of Integer (NoMethodError) ``` I think it would be useful to support integers as shard keys along with symbols. This would simplify shard switching when shards are identified by integer columns in a database. As an example, if there is an `accounts` table with a `shard` integer column which identifies which shard that account's data lives on, this currently would not work: ```ruby account = Account.first ActiveRecord::Base.connected_to(shard: account.shard) do # Raises NoMethodError end ``` A workaround would be to first coerce or serialize the integer into a string but that's unideal: ```ruby account = Account.first ActiveRecord::Base.connected_to(shard: account.shard.to_s.to_sym) do # ... end ``` This makes a `.connects_to` change, coercing the shard key into a symbol _unless_ the key is an integer. From there, passing a symbol or integer to `connected_to(shard: ...)` should both work. In the test, we call: ```ruby ActiveRecord::Base.default_shard = 0 ``` Normally, it would be `:default`. In that case we'd have to mix symbols and integers in the `.connects_to:(shards:)` hash which is pretty ugly. --- activerecord/CHANGELOG.md | 15 +++++ .../lib/active_record/connection_handling.rb | 3 +- .../connection_handlers_sharding_db_test.rb | 61 +++++++++++++++++++ 3 files changed, 78 insertions(+), 1 deletion(-) diff --git a/activerecord/CHANGELOG.md b/activerecord/CHANGELOG.md index 9f8371740a35c..6a5ec5a4fe58a 100644 --- a/activerecord/CHANGELOG.md +++ b/activerecord/CHANGELOG.md @@ -1,3 +1,18 @@ +* Add support for integer shard keys. + ```ruby + # Now accepts symbols as shard keys. + ActiveRecord::Base.connects_to(shards: { + 1: { writing: :primary_shard_one, reading: :primary_shard_one }, + 2: { writing: :primary_shard_two, reading: :primary_shard_two}, + }) + + ActiveRecord::Base.connected_to(shard: 1) do + # .. + end + ``` + + *Nony Dutton* + * Add `ActiveRecord::Base.only_columns` Similar in use case to `ignored_columns` but listing columns to consider rather than the ones diff --git a/activerecord/lib/active_record/connection_handling.rb b/activerecord/lib/active_record/connection_handling.rb index afbfe9acc18e4..cc052d954b7ba 100644 --- a/activerecord/lib/active_record/connection_handling.rb +++ b/activerecord/lib/active_record/connection_handling.rb @@ -100,7 +100,8 @@ def connects_to(database: {}, shards: {}) db_config = resolve_config_for_connection(database_key) self.connection_class = true - connections << connection_handler.establish_connection(db_config, owner_name: self, role: role, shard: shard.to_sym) + shard = shard.to_sym unless shard.is_a? Integer + connections << connection_handler.establish_connection(db_config, owner_name: self, role: role, shard: shard) end end 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 2ce10a5f00a9f..0d70f2d6e6ca5 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 @@ -103,6 +103,67 @@ def test_establish_connection_using_3_levels_config_with_shards_and_replica ENV["RAILS_ENV"] = previous_env end + class IntegerKeysBase < ActiveRecord::Base + self.abstract_class = true + end + + def test_establish_connection_using_3_levels_config_with_integer_keys + previous_env, ENV["RAILS_ENV"] = ENV["RAILS_ENV"], "default_env" + default_shard_was = ActiveRecord::Base.default_shard + ActiveRecord::Base.default_shard = 0 + + config = { + "default_env" => { + "primary" => { "adapter" => "sqlite3", "database" => "test/db/primary.sqlite3" }, + "primary_shard_one" => { "adapter" => "sqlite3", "database" => "test/db/primary_shard_one.sqlite3" }, + "primary_shard_one_replica" => { "adapter" => "sqlite3", "database" => "test/db/primary_shard_one_replica.sqlite3", "replica" => true } + } + } + + @prev_configs, ActiveRecord::Base.configurations = ActiveRecord::Base.configurations, config + + IntegerKeysBase.connects_to(shards: { + 0 => { writing: :primary, reading: :primary }, + 1 => { writing: :primary_shard_one, reading: :primary_shard_one_replica } + }) + + connection_description_name = "ActiveRecord::ConnectionAdapters::ConnectionHandlersShardingDbTest::IntegerKeysBase" + base_pool = ActiveRecord::Base.connection_handler.retrieve_connection_pool(connection_description_name) + default_pool = ActiveRecord::Base.connection_handler.retrieve_connection_pool(connection_description_name, shard: 0) + + assert_equal [0, 1], ActiveRecord::Base.connection_handler.send(:get_pool_manager, connection_description_name).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 + + assert_not_nil pool = ActiveRecord::Base.connection_handler.retrieve_connection_pool(connection_description_name, shard: 1) + assert_equal "test/db/primary_shard_one.sqlite3", pool.db_config.database + assert_equal "primary_shard_one", pool.db_config.name + + assert_not_nil pool = ActiveRecord::Base.connection_handler.retrieve_connection_pool(connection_description_name, role: :reading, shard: 1) + assert_equal "test/db/primary_shard_one_replica.sqlite3", pool.db_config.database + assert_equal "primary_shard_one_replica", pool.db_config.name + + IntegerKeysBase.connected_to(shard: 1) do + assert_includes IntegerKeysBase.lease_connection.query_value("SELECT file FROM pragma_database_list;"), "primary_shard_one.sqlite3" + end + + IntegerKeysBase.connected_to(role: :reading, shard: 1) do + assert_includes IntegerKeysBase.lease_connection.query_value("SELECT file FROM pragma_database_list;"), "primary_shard_one_replica.sqlite3" + end + + assert_raises(ActiveRecord::ConnectionNotDefined) do + IntegerKeysBase.connected_to(shard: 2) do + IntegerKeysBase.lease_connection.query_value("SELECT file FROM pragma_database_list;") + end + end + ensure + ActiveRecord::Base.default_shard = default_shard_was + ActiveRecord::Base.configurations = @prev_configs + ActiveRecord::Base.establish_connection(:arunit) + ENV["RAILS_ENV"] = previous_env + end + def test_switching_connections_via_handler previous_env, ENV["RAILS_ENV"] = ENV["RAILS_ENV"], "default_env" From 7c95c0ba64c2af449cdac98b5ffa6f0d851b18b5 Mon Sep 17 00:00:00 2001 From: Ernesto Tagwerker Date: Mon, 22 Sep 2025 15:33:43 -0400 Subject: [PATCH 0656/1075] Address small a11y errors in the welcome to Rails page - tag was missing alt attribute/value that described image - tag was missing lang attribute/value --- railties/lib/rails/templates/rails/welcome/index.html.erb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/railties/lib/rails/templates/rails/welcome/index.html.erb b/railties/lib/rails/templates/rails/welcome/index.html.erb index a9befa4be9339..2407968cabe99 100644 --- a/railties/lib/rails/templates/rails/welcome/index.html.erb +++ b/railties/lib/rails/templates/rails/welcome/index.html.erb @@ -1,5 +1,5 @@ - + Ruby on Rails <%= Rails.version %> @@ -95,7 +95,7 @@ From 1df7f7eb45f048325f7d4a20bdf1bde894eb4559 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Mon, 22 Sep 2025 21:48:50 +0000 Subject: [PATCH 0657/1075] Remove lock for rdoc gem in Gemfile The problem was fixed in sdoc 2.6.2. See https://github.com/rails/sdoc/releases/tag/v2.6.2 --- Gemfile | 1 - Gemfile.lock | 5 +++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Gemfile b/Gemfile index d423bddec817d..fbb6bddec6912 100644 --- a/Gemfile +++ b/Gemfile @@ -63,7 +63,6 @@ end group :doc do gem "sdoc" - gem "rdoc", "< 6.10" gem "redcarpet", "~> 3.6.1", platforms: :ruby gem "w3c_validators", "~> 1.3.6" gem "rouge" diff --git a/Gemfile.lock b/Gemfile.lock index fa003ff29fa39..4e4621af9b45a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -195,6 +195,7 @@ GEM dotenv (3.1.7) drb (2.2.3) ed25519 (1.3.0) + erb (5.0.2) erubi (1.13.1) et-orbi (1.2.11) tzinfo @@ -487,7 +488,8 @@ GEM rb-inotify (0.11.1) ffi (~> 1.0) rbtree (0.4.6) - rdoc (6.9.1) + rdoc (6.14.2) + erb psych (>= 4.0.0) redcarpet (3.6.1) redis (5.4.1) @@ -789,7 +791,6 @@ DEPENDENCIES rack-cache (~> 1.2) rails! rake (>= 13) - rdoc (< 6.10) redcarpet (~> 3.6.1) redis (>= 4.0.1) redis-namespace From 67a50f45ded1cfc73ae35fa526efc747cb040353 Mon Sep 17 00:00:00 2001 From: Mikey Gough Date: Mon, 22 Sep 2025 15:26:57 -0700 Subject: [PATCH 0658/1075] DRY up test helpers in DatabaseTasks tests --- .../test/cases/tasks/database_tasks_test.rb | 135 ++++-------------- 1 file changed, 29 insertions(+), 106 deletions(-) diff --git a/activerecord/test/cases/tasks/database_tasks_test.rb b/activerecord/test/cases/tasks/database_tasks_test.rb index 8d105172ea38b..4c4063b37eaaf 100644 --- a/activerecord/test/cases/tasks/database_tasks_test.rb +++ b/activerecord/test/cases/tasks/database_tasks_test.rb @@ -48,6 +48,29 @@ def assert_called_for_configs(method_name, configs, &block) ActiveRecord::Tasks::DatabaseTasks.stub(method_name, mock, &block) assert_mock(mock) end + + def with_stubbed_configurations(configurations = @configurations, env: "test") + old_configurations = ActiveRecord::Base.configurations + ActiveRecord::Base.configurations = configurations + ActiveRecord::Tasks::DatabaseTasks.env = env + + yield + ensure + ActiveRecord::Base.configurations = old_configurations + ActiveRecord::Tasks::DatabaseTasks.env = nil + end + + def with_stubbed_configurations_establish_connection(&block) + with_stubbed_configurations do + # To refrain from connecting to a newly created empty DB in + # sqlite3_mem tests + ActiveRecord::Base.connection_handler.stub(:establish_connection, nil, &block) + end + end + + def config_for(env_name, name) + ActiveRecord::Base.configurations.configs_for(env_name: env_name, name: name) + end end ADAPTERS_TASKS = { @@ -414,6 +437,8 @@ def test_db_dir_ignored_if_included_in_schema_dump end class DatabaseTasksCreateAllTest < ActiveRecord::TestCase + include DatabaseTasksHelper + def setup @configurations = { "development" => { "adapter" => "abstract", "database" => "my-db" } } @@ -485,18 +510,6 @@ def test_creates_configurations_with_blank_hosts end end end - - private - def with_stubbed_configurations_establish_connection(&block) - old_configurations = ActiveRecord::Base.configurations - ActiveRecord::Base.configurations = @configurations - - # To refrain from connecting to a newly created empty DB in - # sqlite3_mem tests - ActiveRecord::Base.connection_handler.stub(:establish_connection, nil, &block) - ensure - ActiveRecord::Base.configurations = old_configurations - end end class DatabaseTasksCreateCurrentTest < ActiveRecord::TestCase @@ -606,20 +619,6 @@ def test_establishes_connection_for_the_given_environments end end end - - private - def config_for(env_name, name) - ActiveRecord::Base.configurations.configs_for(env_name: env_name, name: name) - end - - def with_stubbed_configurations_establish_connection(&block) - old_configurations = ActiveRecord::Base.configurations - ActiveRecord::Base.configurations = @configurations - - ActiveRecord::Base.connection_handler.stub(:establish_connection, nil, &block) - ensure - ActiveRecord::Base.configurations = old_configurations - end end class DatabaseTasksCreateCurrentThreeTierTest < ActiveRecord::TestCase @@ -727,20 +726,6 @@ def test_establishes_connection_for_the_given_environments_config end end end - - private - def config_for(env_name, name) - ActiveRecord::Base.configurations.configs_for(env_name: env_name, name: name) - end - - def with_stubbed_configurations_establish_connection(&block) - old_configurations = ActiveRecord::Base.configurations - ActiveRecord::Base.configurations = @configurations - - ActiveRecord::Base.connection_handler.stub(:establish_connection, nil, &block) - ensure - ActiveRecord::Base.configurations = old_configurations - end end class DatabaseTasksDropTest < ActiveRecord::TestCase @@ -758,6 +743,8 @@ class DatabaseTasksDropTest < ActiveRecord::TestCase end class DatabaseTasksDropAllTest < ActiveRecord::TestCase + include DatabaseTasksHelper + def setup @configurations = { development: { "adapter" => "abstract", "database" => "my-db" } } @@ -829,16 +816,6 @@ def test_drops_configurations_with_blank_hosts end end end - - private - def with_stubbed_configurations - old_configurations = ActiveRecord::Base.configurations - ActiveRecord::Base.configurations = @configurations - - yield - ensure - ActiveRecord::Base.configurations = old_configurations - end end class DatabaseTasksDropCurrentTest < ActiveRecord::TestCase @@ -916,20 +893,6 @@ def test_drops_testand_development_databases_when_rails_env_is_development ensure ENV["RAILS_ENV"] = old_env end - - private - def config_for(env_name, name) - ActiveRecord::Base.configurations.configs_for(env_name: env_name, name: name) - end - - def with_stubbed_configurations - old_configurations = ActiveRecord::Base.configurations - ActiveRecord::Base.configurations = @configurations - - yield - ensure - ActiveRecord::Base.configurations = old_configurations - end end class DatabaseTasksDropCurrentThreeTierTest < ActiveRecord::TestCase @@ -1024,20 +987,6 @@ def test_drops_testand_development_databases_when_rails_env_is_development ensure ENV["RAILS_ENV"] = old_env end - - private - def config_for(env_name, name) - ActiveRecord::Base.configurations.configs_for(env_name: env_name, name: name) - end - - def with_stubbed_configurations - old_configurations = ActiveRecord::Base.configurations - ActiveRecord::Base.configurations = @configurations - - yield - ensure - ActiveRecord::Base.configurations = old_configurations - end end class DatabaseTasksMigrationTestCase < ActiveRecord::TestCase @@ -1469,20 +1418,6 @@ def test_truncate_all_development_databases_when_env_is_development ensure ENV["RAILS_ENV"] = old_env end - - private - def config_for(env_name, name) - ActiveRecord::Base.configurations.configs_for(env_name: env_name, name: name) - end - - def with_stubbed_configurations - old_configurations = ActiveRecord::Base.configurations - ActiveRecord::Base.configurations = @configurations - - yield - ensure - ActiveRecord::Base.configurations = old_configurations - end end class DatabaseTasksCharsetTest < ActiveRecord::TestCase @@ -1703,6 +1638,8 @@ def test_check_schema_file end class DatabaseTasksCheckSchemaFileMethods < ActiveRecord::TestCase + include DatabaseTasksHelper + setup do @configurations = { "development" => { "adapter" => "abstract", "database" => "my-db" } } end @@ -1795,19 +1732,5 @@ def test_check_dump_filename_with_schema_env_with_non_primary_databases end end end - - private - def config_for(env_name, name) - ActiveRecord::Base.configurations.configs_for(env_name: env_name, name: name) - end - - def with_stubbed_configurations(configurations = @configurations) - old_configurations = ActiveRecord::Base.configurations - ActiveRecord::Base.configurations = configurations - - yield - ensure - ActiveRecord::Base.configurations = old_configurations - end end end From f36de3e1df80db7b10ceb8eac0e07b7583192667 Mon Sep 17 00:00:00 2001 From: Nicolas Rodriguez Date: Tue, 23 Sep 2025 02:54:34 +0200 Subject: [PATCH 0659/1075] Make CodeStatistics pattern overridable --- railties/lib/rails/code_statistics.rb | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/railties/lib/rails/code_statistics.rb b/railties/lib/rails/code_statistics.rb index 40a74785fdd57..87c5bbb332941 100644 --- a/railties/lib/rails/code_statistics.rb +++ b/railties/lib/rails/code_statistics.rb @@ -42,8 +42,11 @@ class CodeStatistics HEADERS = { lines: " Lines", code_lines: " LOC", classes: "Classes", methods: "Methods" } + PATTERN = /^(?!\.).*?\.(rb|js|ts|css|scss|coffee|rake|erb)$/ + class_attribute :directories, default: DIRECTORIES class_attribute :test_types, default: TEST_TYPES + class_attribute :pattern, default: PATTERN # Add directories to the output of the bin/rails stats command. # @@ -81,7 +84,7 @@ def calculate_statistics Hash[@pairs.map { |pair| [pair.first, calculate_directory_statistics(pair.last)] }] end - def calculate_directory_statistics(directory, pattern = /^(?!\.).*?\.(rb|js|ts|css|scss|coffee|rake|erb)$/) + def calculate_directory_statistics(directory, pattern = self.class.pattern) stats = Rails::CodeStatisticsCalculator.new Dir.foreach(directory) do |file_name| From 00dc4bf1f1413f34197f3c2be55a8fb8f3bf47af Mon Sep 17 00:00:00 2001 From: Takuya Noguchi Date: Sun, 2 Mar 2025 13:26:29 +0000 Subject: [PATCH 0660/1075] chore(deps-dev): update eslint from 8.57.1 to 9.24.0 with Flat Config Signed-off-by: Takuya Noguchi --- actioncable/.eslintrc | 20 - .../app/javascript/action_cable/index.js | 6 +- .../javascript/action_cable/subscriptions.js | 2 +- actioncable/package.json | 6 +- activestorage/.eslintrc | 19 - activestorage/package.json | 6 +- eslint.config.mjs | 62 + yarn.lock | 1581 ++++++++++++----- 8 files changed, 1189 insertions(+), 513 deletions(-) delete mode 100644 actioncable/.eslintrc delete mode 100644 activestorage/.eslintrc create mode 100644 eslint.config.mjs diff --git a/actioncable/.eslintrc b/actioncable/.eslintrc deleted file mode 100644 index b85ef26b314cd..0000000000000 --- a/actioncable/.eslintrc +++ /dev/null @@ -1,20 +0,0 @@ -{ - "extends": "eslint:recommended", - "rules": { - "semi": ["error", "never"], - "quotes": ["error", "double"], - "no-unused-vars": ["error", { "vars": "all", "args": "none" }], - "no-console": "off" - }, - "plugins": [ - "import" - ], - "env": { - "browser": true, - "es6": true - }, - "parserOptions": { - "ecmaVersion": 6, - "sourceType": "module" - } -} diff --git a/actioncable/app/javascript/action_cable/index.js b/actioncable/app/javascript/action_cable/index.js index 3e650bc120bc0..62b904f5f7ef3 100644 --- a/actioncable/app/javascript/action_cable/index.js +++ b/actioncable/app/javascript/action_cable/index.js @@ -1,12 +1,12 @@ +import adapters from "./adapters" import Connection from "./connection" import ConnectionMonitor from "./connection_monitor" import Consumer, { createWebSocketURL } from "./consumer" import INTERNAL from "./internal" +import logger from "./logger" import Subscription from "./subscription" -import Subscriptions from "./subscriptions" import SubscriptionGuarantor from "./subscription_guarantor" -import adapters from "./adapters" -import logger from "./logger" +import Subscriptions from "./subscriptions" export { Connection, diff --git a/actioncable/app/javascript/action_cable/subscriptions.js b/actioncable/app/javascript/action_cable/subscriptions.js index ec41ccbf75ba6..0f166057ad6ea 100644 --- a/actioncable/app/javascript/action_cable/subscriptions.js +++ b/actioncable/app/javascript/action_cable/subscriptions.js @@ -1,6 +1,6 @@ +import logger from "./logger" import Subscription from "./subscription" import SubscriptionGuarantor from "./subscription_guarantor" -import logger from "./logger" // Collection class for creating (and internally managing) channel subscriptions. // The only method intended to be triggered by the user is ActionCable.Subscriptions#create, diff --git a/actioncable/package.json b/actioncable/package.json index 624e638ee1af8..4b5cda4d31ff0 100644 --- a/actioncable/package.json +++ b/actioncable/package.json @@ -24,10 +24,12 @@ }, "homepage": "https://rubyonrails.org/", "devDependencies": { + "@eslint/js":"^9.24.0", "@rollup/plugin-commonjs": "^19.0.1", "@rollup/plugin-node-resolve": "^11.0.1", - "eslint": "^8.40.0", - "eslint-plugin-import": "^2.29.0", + "eslint": "^9.24.0", + "eslint-plugin-import": "^2.31.0", + "globals": "^14.0.0", "karma": "^6.4.2", "karma-chrome-launcher": "^2.2.0", "karma-qunit": "^2.1.0", diff --git a/activestorage/.eslintrc b/activestorage/.eslintrc deleted file mode 100644 index 3d9ecd4bce5eb..0000000000000 --- a/activestorage/.eslintrc +++ /dev/null @@ -1,19 +0,0 @@ -{ - "extends": "eslint:recommended", - "rules": { - "semi": ["error", "never"], - "quotes": ["error", "double"], - "no-unused-vars": ["error", { "vars": "all", "args": "none" }] - }, - "plugins": [ - "import" - ], - "env": { - "browser": true, - "es6": true - }, - "parserOptions": { - "ecmaVersion": 6, - "sourceType": "module" - } -} diff --git a/activestorage/package.json b/activestorage/package.json index df9b72df8563a..56628bef6e853 100644 --- a/activestorage/package.json +++ b/activestorage/package.json @@ -22,10 +22,12 @@ "spark-md5": "^3.0.1" }, "devDependencies": { + "@eslint/js":"^9.24.0", "@rollup/plugin-node-resolve": "^11.0.1", "@rollup/plugin-commonjs": "^19.0.1", - "eslint": "^8.40.0", - "eslint-plugin-import": "^2.29.0", + "eslint": "^9.24.0", + "eslint-plugin-import": "^2.31.0", + "globals": "^14.0.0", "rollup": "^2.35.1", "rollup-plugin-terser": "^7.0.2" }, diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000000000..f6f0701543842 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,62 @@ +import importPlugin from "eslint-plugin-import"; +import globals from "globals"; +import pluginJs from "@eslint/js"; + +export default [ + pluginJs.configs.recommended, + importPlugin.flatConfigs.recommended, + { + languageOptions: { + globals: { + ...globals.browser, + }, + + ecmaVersion: 6, + sourceType: "module", + }, + }, + + { + rules: { + semi: ["error", "never"], + quotes: ["error", "double"], + "no-unused-vars": [ + "error", + { + vars: "all", + args: "none", + }, + ], + "import/order": [ + "error", + { + alphabetize: { + order: "asc", + caseInsensitive: true, + }, + groups: [["builtin", "external", "internal"]], + }, + ], + }, + }, + { + files: ["actioncable/**"], + rules: { + "no-console": "off", + }, + }, + { + files: [ + "activestorage/app/javascript/activestorage/direct_upload.js", + "activestorage/app/javascript/activestorage/index.js", + ], + rules: { + "import/order": [ + "error", + { + groups: [["builtin", "external", "internal"]], + }, + ], + }, + }, +]; diff --git a/yarn.lock b/yarn.lock index e7b457ec6fccc..6c8a093023581 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3,25 +3,27 @@ "@babel/code-frame@^7.10.4": - version "7.22.5" - resolved "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.5.tgz" - integrity sha512-Xmwn266vad+6DAqEB2A6V/CcZVp62BbwVmcOJc2RPuwih1kw02TjQvWVWlcKGbBPd+8/0V5DEkOcizRGYsspYQ== + version "7.25.7" + resolved "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.25.7.tgz" + integrity sha512-0xZJFNE5XMpENsgfHYTw8FbX4kv53mFLn2i3XPoq69LyhYSCBJtitaHx9QnsVTrsogI4Z3+HtEfZ2/GFPOtf5g== dependencies: - "@babel/highlight" "^7.22.5" + "@babel/highlight" "^7.25.7" + picocolors "^1.0.0" -"@babel/helper-validator-identifier@^7.22.5": - version "7.22.5" - resolved "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.5.tgz" - integrity sha512-aJXu+6lErq8ltp+JhkJUfk1MTGyuA4v7f3pA+BJ5HLfNC6nAQ0Cpi9uOquUj8Hehg0aUiHzWQbOVJGao6ztBAQ== +"@babel/helper-validator-identifier@^7.25.7": + version "7.25.7" + resolved "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.7.tgz" + integrity sha512-AM6TzwYqGChO45oiuPqwL2t20/HdMC1rTPAesnBCgPCSF1x3oN9MVUwQV2iyz4xqWrctwK5RNC8LV22kaQCNYg== -"@babel/highlight@^7.22.5": - version "7.22.5" - resolved "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.5.tgz" - integrity sha512-BSKlD1hgnedS5XRnGOljZawtag7H1yPfQp0tdNJCHoH6AZ+Pcm9VvkrK59/Yy593Ypg0zMxH2BxD1VPYUQ7UIw== +"@babel/highlight@^7.25.7": + version "7.25.7" + resolved "https://registry.npmjs.org/@babel/highlight/-/highlight-7.25.7.tgz" + integrity sha512-iYyACpW3iW8Fw+ZybQK+drQre+ns/tKpXbNESfrhNnPLIklLbXr7MYJ6gPEd0iETGLOK+SxMjVvKb/ffmk+FEw== dependencies: - "@babel/helper-validator-identifier" "^7.22.5" - chalk "^2.0.0" + "@babel/helper-validator-identifier" "^7.25.7" + chalk "^2.4.2" js-tokens "^4.0.0" + picocolors "^1.0.0" "@colors/colors@1.5.0": version "1.5.0" @@ -35,91 +37,134 @@ dependencies: eslint-visitor-keys "^3.3.0" -"@eslint-community/regexpp@^4.6.1": - version "4.11.1" - resolved "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.11.1.tgz" - integrity sha512-m4DVN9ZqskZoLU5GlWZadwDnYo3vAEydiUayB9widCl9ffWx2IvPnp6n3on5rJmziJSw9Bv+Z3ChDVdMwXCY8Q== +"@eslint-community/regexpp@^4.12.1": + version "4.12.1" + resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.1.tgz#cfc6cffe39df390a3841cde2abccf92eaa7ae0e0" + integrity sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ== -"@eslint/eslintrc@^2.1.4": - version "2.1.4" - resolved "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz" - integrity sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ== +"@eslint/config-array@^0.20.0": + version "0.20.1" + resolved "https://registry.yarnpkg.com/@eslint/config-array/-/config-array-0.20.1.tgz#454f89be82b0e5b1ae872c154c7e2f3dd42c3979" + integrity sha512-OL0RJzC/CBzli0DrrR31qzj6d6i6Mm3HByuhflhl4LOBiWxN+3i6/t/ZQQNii4tjksXi8r2CRW1wMpWA2ULUEw== + dependencies: + "@eslint/object-schema" "^2.1.6" + debug "^4.3.1" + minimatch "^3.1.2" + +"@eslint/config-helpers@^0.2.0": + version "0.2.3" + resolved "https://registry.yarnpkg.com/@eslint/config-helpers/-/config-helpers-0.2.3.tgz#39d6da64ed05d7662659aa7035b54cd55a9f3672" + integrity sha512-u180qk2Um1le4yf0ruXH3PYFeEZeYC3p/4wCTKrr2U1CmGdzGi3KtY0nuPDH48UJxlKCC5RDzbcbh4X0XlqgHg== + +"@eslint/core@^0.12.0": + version "0.12.0" + resolved "https://registry.yarnpkg.com/@eslint/core/-/core-0.12.0.tgz#5f960c3d57728be9f6c65bd84aa6aa613078798e" + integrity sha512-cmrR6pytBuSMTaBweKoGMwu3EiHiEC+DoyupPmlZ0HxBJBtIxwe+j/E4XPIKNx+Q74c8lXKPwYawBf5glsTkHg== + dependencies: + "@types/json-schema" "^7.0.15" + +"@eslint/core@^0.13.0": + version "0.13.0" + resolved "https://registry.yarnpkg.com/@eslint/core/-/core-0.13.0.tgz#bf02f209846d3bf996f9e8009db62df2739b458c" + integrity sha512-yfkgDw1KR66rkT5A8ci4irzDysN7FRpq3ttJolR88OqQikAWqwA8j5VZyas+vjyBNFIJ7MfybJ9plMILI2UrCw== + dependencies: + "@types/json-schema" "^7.0.15" + +"@eslint/eslintrc@^3.3.1": + version "3.3.1" + resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-3.3.1.tgz#e55f7f1dd400600dd066dbba349c4c0bac916964" + integrity sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ== dependencies: ajv "^6.12.4" debug "^4.3.2" - espree "^9.6.0" - globals "^13.19.0" + espree "^10.0.1" + globals "^14.0.0" ignore "^5.2.0" import-fresh "^3.2.1" js-yaml "^4.1.0" minimatch "^3.1.2" strip-json-comments "^3.1.1" -"@eslint/js@8.57.1": - version "8.57.1" - resolved "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz" - integrity sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q== +"@eslint/js@9.24.0", "@eslint/js@^9.24.0": + version "9.24.0" + resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.24.0.tgz#685277980bb7bf84ecc8e4e133ccdda7545a691e" + integrity sha512-uIY/y3z0uvOGX8cp1C2fiC4+ZmBhp6yZWkojtHL1YEMnRt1Y63HB9TM17proGEmeG7HeUY+UP36F0aknKYTpYA== -"@humanwhocodes/config-array@^0.13.0": - version "0.13.0" - resolved "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz" - integrity sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw== +"@eslint/object-schema@^2.1.6": + version "2.1.6" + resolved "https://registry.yarnpkg.com/@eslint/object-schema/-/object-schema-2.1.6.tgz#58369ab5b5b3ca117880c0f6c0b0f32f6950f24f" + integrity sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA== + +"@eslint/plugin-kit@^0.2.7": + version "0.2.8" + resolved "https://registry.yarnpkg.com/@eslint/plugin-kit/-/plugin-kit-0.2.8.tgz#47488d8f8171b5d4613e833313f3ce708e3525f8" + integrity sha512-ZAoA40rNMPwSm+AeHpCq8STiNAwzWLJuP8Xv4CHIc9wv/PSuExjMrmjfYNj682vW0OOiZ1HKxzvjQr9XZIisQA== dependencies: - "@humanwhocodes/object-schema" "^2.0.3" - debug "^4.3.1" - minimatch "^3.0.5" + "@eslint/core" "^0.13.0" + levn "^0.4.1" + +"@humanfs/core@^0.19.1": + version "0.19.1" + resolved "https://registry.yarnpkg.com/@humanfs/core/-/core-0.19.1.tgz#17c55ca7d426733fe3c561906b8173c336b40a77" + integrity sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA== + +"@humanfs/node@^0.16.6": + version "0.16.7" + resolved "https://registry.yarnpkg.com/@humanfs/node/-/node-0.16.7.tgz#822cb7b3a12c5a240a24f621b5a2413e27a45f26" + integrity sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ== + dependencies: + "@humanfs/core" "^0.19.1" + "@humanwhocodes/retry" "^0.4.0" "@humanwhocodes/module-importer@^1.0.1": version "1.0.1" resolved "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz" integrity sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA== -"@humanwhocodes/object-schema@^2.0.3": - version "2.0.3" - resolved "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz" - integrity sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA== +"@humanwhocodes/retry@^0.4.0", "@humanwhocodes/retry@^0.4.2": + version "0.4.3" + resolved "https://registry.yarnpkg.com/@humanwhocodes/retry/-/retry-0.4.3.tgz#c2b9d2e374ee62c586d3adbea87199b1d7a7a6ba" + integrity sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ== -"@jridgewell/source-map@^0.3.3": - version "0.3.4" - resolved "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.4.tgz" - integrity sha512-KE/SxsDqNs3rrWwFHcRh15ZLVFrI0YoZtgAdIyIq9k5hUNmiWRXXThPomIxHuL20sLdgzbDFyvkUMna14bvtrw== - -"@nodelib/fs.scandir@2.1.5": - version "2.1.5" - resolved "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz" - integrity sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g== +"@jridgewell/gen-mapping@^0.3.5": + version "0.3.5" + resolved "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz" + integrity sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg== dependencies: - "@nodelib/fs.stat" "2.0.5" - run-parallel "^1.1.9" - -"@nodelib/fs.stat@2.0.5": - version "2.0.5" - resolved "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz" - integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A== + "@jridgewell/set-array" "^1.2.1" + "@jridgewell/sourcemap-codec" "^1.4.10" + "@jridgewell/trace-mapping" "^0.3.24" -"@nodelib/fs.walk@^1.2.8": - version "1.2.8" - resolved "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz" - integrity sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg== - dependencies: - "@nodelib/fs.scandir" "2.1.5" - fastq "^1.6.0" +"@jridgewell/resolve-uri@^3.1.0": + version "3.1.2" + resolved "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz" + integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw== -"@rails/actioncable@file:/workspaces/rails/actioncable": - version "8.1.0-beta1" - resolved "file:actioncable" +"@jridgewell/set-array@^1.2.1": + version "1.2.1" + resolved "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz" + integrity sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A== -"@rails/actiontext@file:/workspaces/rails/actiontext": - version "8.1.0-beta1" - resolved "file:actiontext" +"@jridgewell/source-map@^0.3.3": + version "0.3.6" + resolved "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz" + integrity sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ== dependencies: - "@rails/activestorage" ">= 8.1.0-alpha" + "@jridgewell/gen-mapping" "^0.3.5" + "@jridgewell/trace-mapping" "^0.3.25" -"@rails/activestorage@>= 8.1.0-alpha", "@rails/activestorage@file:/workspaces/rails/activestorage": - version "8.1.0-beta1" - resolved "file:activestorage" +"@jridgewell/sourcemap-codec@^1.4.10", "@jridgewell/sourcemap-codec@^1.4.14": + version "1.5.0" + resolved "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz" + integrity sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ== + +"@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25": + version "0.3.25" + resolved "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz" + integrity sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ== dependencies: - spark-md5 "^3.0.1" + "@jridgewell/resolve-uri" "^3.1.0" + "@jridgewell/sourcemap-codec" "^1.4.14" "@rollup/plugin-commonjs@^19.0.1": version "19.0.2" @@ -155,6 +200,11 @@ estree-walker "^1.0.1" picomatch "^2.2.2" +"@rtsao/scc@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@rtsao/scc/-/scc-1.1.0.tgz#927dd2fae9bc3361403ac2c7a00c32ddce9ad7e8" + integrity sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g== + "@socket.io/component-emitter@~3.1.0": version "3.1.2" resolved "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz" @@ -177,15 +227,27 @@ resolved "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz" integrity sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw== +"@types/estree@^1.0.6": + version "1.0.8" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e" + integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== + +"@types/json-schema@^7.0.15": + version "7.0.15" + resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" + integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== + "@types/json5@^0.0.29": version "0.0.29" resolved "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz" integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ== "@types/node@*", "@types/node@>=10.0.0": - version "20.3.2" - resolved "https://registry.npmjs.org/@types/node/-/node-20.3.2.tgz" - integrity sha512-vOBLVQeCQfIcF/2Y7eKFTqrMnizK5lRNQ7ykML/5RuwVXVWxYkgwS7xbt4B6fKCUPgbSL5FSsjHQpaGQP/dQmw== + version "22.7.6" + resolved "https://registry.npmjs.org/@types/node/-/node-22.7.6.tgz" + integrity sha512-/d7Rnj0/ExXDMcioS78/kf1lMzYk4BZV8MZGTBKzTGZ6/406ukkbYlIsZmMPhcR5KlkunDHQLrtAVmSq7r+mSw== + dependencies: + undici-types "~6.19.2" "@types/resolve@1.17.1": version "1.17.1" @@ -194,11 +256,6 @@ dependencies: "@types/node" "*" -"@ungap/structured-clone@^1.2.0": - version "1.2.0" - resolved "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz" - integrity sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ== - accepts@~1.3.4: version "1.3.8" resolved "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz" @@ -212,7 +269,12 @@ acorn-jsx@^5.3.2: resolved "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz" integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== -"acorn@^6.0.0 || ^7.0.0 || ^8.0.0", acorn@^8.8.2, acorn@^8.9.0: +acorn@^8.15.0: + version "8.15.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.15.0.tgz#a360898bc415edaac46c8241f6383975b930b816" + integrity sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg== + +acorn@^8.8.2: version "8.12.1" resolved "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz" integrity sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg== @@ -222,13 +284,6 @@ adm-zip@~0.4.3: resolved "https://registry.npmjs.org/adm-zip/-/adm-zip-0.4.16.tgz" integrity sha512-TFi4HBKSGfIKsK5YCkKaaFG2m4PEDyViZmEwof3MTIgzimHLto6muaHVpbrljdIvIrFZzEq/p4nafOeLcYegrg== -agent-base@^4.3.0: - version "4.3.0" - resolved "https://registry.npmjs.org/agent-base/-/agent-base-4.3.0.tgz" - integrity sha512-salcGninV0nPrwpGNn4VTXBb1SOuXQBiqbrNXoeizJsHrsL6ERFM2Ne3JUSBWRE6aeNJI2ROP/WEEIDUiDe3cg== - dependencies: - es6-promisify "^5.0.0" - agent-base@6: version "6.0.2" resolved "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz" @@ -236,6 +291,13 @@ agent-base@6: dependencies: debug "4" +agent-base@^4.3.0: + version "4.3.0" + resolved "https://registry.npmjs.org/agent-base/-/agent-base-4.3.0.tgz" + integrity sha512-salcGninV0nPrwpGNn4VTXBb1SOuXQBiqbrNXoeizJsHrsL6ERFM2Ne3JUSBWRE6aeNJI2ROP/WEEIDUiDe3cg== + dependencies: + es6-promisify "^5.0.0" + ajv@^6.12.3, ajv@^6.12.4: version "6.12.6" resolved "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz" @@ -315,27 +377,40 @@ array-buffer-byte-length@^1.0.1: call-bind "^1.0.5" is-array-buffer "^3.0.4" -array-includes@^3.1.7: - version "3.1.7" - resolved "https://registry.npmjs.org/array-includes/-/array-includes-3.1.7.tgz" - integrity sha512-dlcsNBIiWhPkHdOEEKnehA+RNUWDc4UqFtnIXU4uuYDPtA4LDkr7qip2p0VvFAEXNDr0yWZ9PJyIRiGjRLQzwQ== +array-buffer-byte-length@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz#384d12a37295aec3769ab022ad323a18a51ccf8b" + integrity sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw== dependencies: - call-bind "^1.0.2" - define-properties "^1.2.0" - es-abstract "^1.22.1" - get-intrinsic "^1.2.1" - is-string "^1.0.7" + call-bound "^1.0.3" + is-array-buffer "^3.0.5" -array.prototype.findlastindex@^1.2.3: - version "1.2.3" - resolved "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.3.tgz" - integrity sha512-LzLoiOMAxvy+Gd3BAq3B7VeIgPdo+Q8hthvKtXybMvRV0jrXfJM/t8mw7nNlpEcVlVUnCnM2KSX4XU5HmpodOA== +array-includes@^3.1.8: + version "3.1.9" + resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.1.9.tgz#1f0ccaa08e90cdbc3eb433210f903ad0f17c3f3a" + integrity sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ== dependencies: - call-bind "^1.0.2" - define-properties "^1.2.0" - es-abstract "^1.22.1" - es-shim-unscopables "^1.0.0" - get-intrinsic "^1.2.1" + call-bind "^1.0.8" + call-bound "^1.0.4" + define-properties "^1.2.1" + es-abstract "^1.24.0" + es-object-atoms "^1.1.1" + get-intrinsic "^1.3.0" + is-string "^1.1.1" + math-intrinsics "^1.1.0" + +array.prototype.findlastindex@^1.2.5: + version "1.2.6" + resolved "https://registry.yarnpkg.com/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz#cfa1065c81dcb64e34557c9b81d012f6a421c564" + integrity sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.4" + define-properties "^1.2.1" + es-abstract "^1.23.9" + es-errors "^1.3.0" + es-object-atoms "^1.1.1" + es-shim-unscopables "^1.1.0" array.prototype.flat@^1.3.2: version "1.3.2" @@ -371,6 +446,19 @@ arraybuffer.prototype.slice@^1.0.3: is-array-buffer "^3.0.4" is-shared-array-buffer "^1.0.2" +arraybuffer.prototype.slice@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz#9d760d84dbdd06d0cbf92c8849615a1a7ab3183c" + integrity sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ== + dependencies: + array-buffer-byte-length "^1.0.1" + call-bind "^1.0.8" + define-properties "^1.2.1" + es-abstract "^1.23.5" + es-errors "^1.3.0" + get-intrinsic "^1.2.6" + is-array-buffer "^3.0.4" + asn1@~0.2.3: version "0.2.6" resolved "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz" @@ -378,11 +466,16 @@ asn1@~0.2.3: dependencies: safer-buffer "~2.1.0" -assert-plus@^1.0.0, assert-plus@1.0.0: +assert-plus@1.0.0, assert-plus@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz" integrity sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw== +async-function@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/async-function/-/async-function-1.0.0.tgz#509c9fca60eaf85034c6829838188e4e4c8ffb2b" + integrity sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA== + async@^2.0.0, async@^2.1.2, async@^2.6.3: version "2.6.4" resolved "https://registry.npmjs.org/async/-/async-2.6.4.tgz" @@ -422,7 +515,7 @@ base64-js@^1.3.1: resolved "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz" integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== -base64id@~2.0.0, base64id@2.0.0: +base64id@2.0.0, base64id@~2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz" integrity sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog== @@ -509,15 +602,15 @@ bytes@3.1.2: resolved "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz" integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== -call-bind@^1.0.2: +call-bind-apply-helpers@^1.0.0, call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2: version "1.0.2" - resolved "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz" - integrity sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA== + resolved "https://registry.yarnpkg.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz#4b5428c222be985d79c3d82657479dbe0b59b2d6" + integrity sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ== dependencies: - function-bind "^1.1.1" - get-intrinsic "^1.0.2" + es-errors "^1.3.0" + function-bind "^1.1.2" -call-bind@^1.0.5, call-bind@^1.0.6, call-bind@^1.0.7: +call-bind@^1.0.2, call-bind@^1.0.5, call-bind@^1.0.6, call-bind@^1.0.7: version "1.0.7" resolved "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz" integrity sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w== @@ -528,6 +621,24 @@ call-bind@^1.0.5, call-bind@^1.0.6, call-bind@^1.0.7: get-intrinsic "^1.2.4" set-function-length "^1.2.1" +call-bind@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.8.tgz#0736a9660f537e3388826f440d5ec45f744eaa4c" + integrity sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww== + dependencies: + call-bind-apply-helpers "^1.0.0" + es-define-property "^1.0.0" + get-intrinsic "^1.2.4" + set-function-length "^1.2.2" + +call-bound@^1.0.2, call-bound@^1.0.3, call-bound@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/call-bound/-/call-bound-1.0.4.tgz#238de935d2a2a692928c538c7ccfa91067fd062a" + integrity sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg== + dependencies: + call-bind-apply-helpers "^1.0.2" + get-intrinsic "^1.3.0" + callsites@^3.0.0: version "3.1.0" resolved "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz" @@ -538,7 +649,7 @@ caseless@~0.12.0: resolved "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz" integrity sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw== -chalk@^2.0.0: +chalk@^2.4.2: version "2.4.2" resolved "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz" integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== @@ -593,16 +704,16 @@ color-convert@^2.0.1: dependencies: color-name "~1.1.4" -color-name@~1.1.4: - version "1.1.4" - resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz" - integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== - color-name@1.1.3: version "1.1.3" resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz" integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw== +color-name@~1.1.4: + version "1.1.4" + resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz" + integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== + combined-stream@^1.0.6, combined-stream@~1.0.6: version "1.0.8" resolved "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz" @@ -610,16 +721,16 @@ combined-stream@^1.0.6, combined-stream@~1.0.6: dependencies: delayed-stream "~1.0.0" -commander@^2.20.0: - version "2.20.3" - resolved "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz" - integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== - commander@7.2.0: version "7.2.0" resolved "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz" integrity sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw== +commander@^2.20.0: + version "2.20.3" + resolved "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz" + integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== + commondir@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz" @@ -655,21 +766,21 @@ content-type@~1.0.5: resolved "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz" integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== -cookie@~0.4.1: - version "0.4.2" - resolved "https://registry.npmjs.org/cookie/-/cookie-0.4.2.tgz" - integrity sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA== - -core-util-is@~1.0.0: - version "1.0.3" - resolved "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz" - integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ== +cookie@~0.7.2: + version "0.7.2" + resolved "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz" + integrity sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w== core-util-is@1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz" integrity sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ== +core-util-is@~1.0.0: + version "1.0.3" + resolved "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz" + integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ== + cors@~2.8.5: version "2.8.5" resolved "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz" @@ -678,13 +789,6 @@ cors@~2.8.5: object-assign "^4" vary "^1" -crc@^3.4.4: - version "3.8.0" - resolved "https://registry.npmjs.org/crc/-/crc-3.8.0.tgz" - integrity sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ== - dependencies: - buffer "^5.1.0" - crc32-stream@^3.0.1: version "3.0.1" resolved "https://registry.npmjs.org/crc32-stream/-/crc32-stream-3.0.1.tgz" @@ -693,10 +797,17 @@ crc32-stream@^3.0.1: crc "^3.4.4" readable-stream "^3.4.0" -cross-spawn@^7.0.2: - version "7.0.3" - resolved "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz" - integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== +crc@^3.4.4: + version "3.8.0" + resolved "https://registry.npmjs.org/crc/-/crc-3.8.0.tgz" + integrity sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ== + dependencies: + buffer "^5.1.0" + +cross-spawn@^7.0.6: + version "7.0.6" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" + integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== dependencies: path-key "^3.1.0" shebang-command "^2.0.0" @@ -723,6 +834,15 @@ data-view-buffer@^1.0.1: es-errors "^1.3.0" is-data-view "^1.0.1" +data-view-buffer@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/data-view-buffer/-/data-view-buffer-1.0.2.tgz#211a03ba95ecaf7798a8c7198d79536211f88570" + integrity sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ== + dependencies: + call-bound "^1.0.3" + es-errors "^1.3.0" + is-data-view "^1.0.2" + data-view-byte-length@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.1.tgz" @@ -732,6 +852,15 @@ data-view-byte-length@^1.0.1: es-errors "^1.3.0" is-data-view "^1.0.1" +data-view-byte-length@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz#9e80f7ca52453ce3e93d25a35318767ea7704735" + integrity sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ== + dependencies: + call-bound "^1.0.3" + es-errors "^1.3.0" + is-data-view "^1.0.2" + data-view-byte-offset@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.0.tgz" @@ -741,38 +870,40 @@ data-view-byte-offset@^1.0.0: es-errors "^1.3.0" is-data-view "^1.0.1" +data-view-byte-offset@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz#068307f9b71ab76dbbe10291389e020856606191" + integrity sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + is-data-view "^1.0.1" + date-format@^4.0.14: version "4.0.14" resolved "https://registry.npmjs.org/date-format/-/date-format-4.0.14.tgz" integrity sha512-39BOQLs9ZjKh0/patS9nrT8wc3ioX3/eA/zgbKNopnF2wCqJEoxywwwElATYvRsXdnOxA/OQeQoFZ3rFjVajhg== -debug@^3.1.0: - version "3.2.7" - resolved "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz" - integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ== - dependencies: - ms "^2.1.1" - -debug@^3.2.7: - version "3.2.7" - resolved "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz" - integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ== +debug@2.6.9: + version "2.6.9" + resolved "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz" + integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== dependencies: - ms "^2.1.1" + ms "2.0.0" -debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@~4.3.1, debug@~4.3.2, debug@~4.3.4, debug@4: +debug@4, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@~4.3.1, debug@~4.3.2, debug@~4.3.4: version "4.3.4" resolved "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz" integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== dependencies: ms "2.1.2" -debug@2.6.9: - version "2.6.9" - resolved "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz" - integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== +debug@^3.1.0, debug@^3.2.7: + version "3.2.7" + resolved "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz" + integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ== dependencies: - ms "2.0.0" + ms "^2.1.1" deep-is@^0.1.3: version "0.1.4" @@ -837,13 +968,6 @@ doctrine@^2.1.0: dependencies: esutils "^2.0.2" -doctrine@^3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz" - integrity sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w== - dependencies: - esutils "^2.0.2" - dom-serialize@^2.2.1: version "2.2.1" resolved "https://registry.npmjs.org/dom-serialize/-/dom-serialize-2.2.1.tgz" @@ -854,6 +978,15 @@ dom-serialize@^2.2.1: extend "^3.0.0" void-elements "^2.0.0" +dunder-proto@^1.0.0, dunder-proto@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a" + integrity sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A== + dependencies: + call-bind-apply-helpers "^1.0.1" + es-errors "^1.3.0" + gopd "^1.2.0" + ecc-jsbn@~0.1.1: version "0.1.2" resolved "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz" @@ -889,17 +1022,17 @@ engine.io-parser@~5.2.1: resolved "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz" integrity sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q== -engine.io@~6.5.2: - version "6.5.5" - resolved "https://registry.npmjs.org/engine.io/-/engine.io-6.5.5.tgz" - integrity sha512-C5Pn8Wk+1vKBoHghJODM63yk8MvrO9EWZUfkAt5HAqIgPE4/8FF0PEGHXtEd40l223+cE5ABWuPzm38PHFXfMA== +engine.io@~6.6.0: + version "6.6.2" + resolved "https://registry.npmjs.org/engine.io/-/engine.io-6.6.2.tgz" + integrity sha512-gmNvsYi9C8iErnZdVcJnvCpSKbWTt1E8+JZo8b+daLninywUWi5NQ5STSHZ9rFjFO7imNcvb8Pc5pe/wMR5xEw== dependencies: "@types/cookie" "^0.4.1" "@types/cors" "^2.8.12" "@types/node" ">=10.0.0" accepts "~1.3.4" base64id "2.0.0" - cookie "~0.4.1" + cookie "~0.7.2" cors "~2.8.5" debug "~4.3.1" engine.io-parser "~5.2.1" @@ -962,6 +1095,66 @@ es-abstract@^1.22.1, es-abstract@^1.22.3, es-abstract@^1.23.0: unbox-primitive "^1.0.2" which-typed-array "^1.1.15" +es-abstract@^1.23.2, es-abstract@^1.23.5, es-abstract@^1.23.9, es-abstract@^1.24.0: + version "1.24.0" + resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.24.0.tgz#c44732d2beb0acc1ed60df840869e3106e7af328" + integrity sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg== + dependencies: + array-buffer-byte-length "^1.0.2" + arraybuffer.prototype.slice "^1.0.4" + available-typed-arrays "^1.0.7" + call-bind "^1.0.8" + call-bound "^1.0.4" + data-view-buffer "^1.0.2" + data-view-byte-length "^1.0.2" + data-view-byte-offset "^1.0.1" + es-define-property "^1.0.1" + es-errors "^1.3.0" + es-object-atoms "^1.1.1" + es-set-tostringtag "^2.1.0" + es-to-primitive "^1.3.0" + function.prototype.name "^1.1.8" + get-intrinsic "^1.3.0" + get-proto "^1.0.1" + get-symbol-description "^1.1.0" + globalthis "^1.0.4" + gopd "^1.2.0" + has-property-descriptors "^1.0.2" + has-proto "^1.2.0" + has-symbols "^1.1.0" + hasown "^2.0.2" + internal-slot "^1.1.0" + is-array-buffer "^3.0.5" + is-callable "^1.2.7" + is-data-view "^1.0.2" + is-negative-zero "^2.0.3" + is-regex "^1.2.1" + is-set "^2.0.3" + is-shared-array-buffer "^1.0.4" + is-string "^1.1.1" + is-typed-array "^1.1.15" + is-weakref "^1.1.1" + math-intrinsics "^1.1.0" + object-inspect "^1.13.4" + object-keys "^1.1.1" + object.assign "^4.1.7" + own-keys "^1.0.1" + regexp.prototype.flags "^1.5.4" + safe-array-concat "^1.1.3" + safe-push-apply "^1.0.0" + safe-regex-test "^1.1.0" + set-proto "^1.0.0" + stop-iteration-iterator "^1.1.0" + string.prototype.trim "^1.2.10" + string.prototype.trimend "^1.0.9" + string.prototype.trimstart "^1.0.8" + typed-array-buffer "^1.0.3" + typed-array-byte-length "^1.0.3" + typed-array-byte-offset "^1.0.4" + typed-array-length "^1.0.7" + unbox-primitive "^1.1.0" + which-typed-array "^1.1.19" + es-define-property@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz" @@ -969,6 +1162,11 @@ es-define-property@^1.0.0: dependencies: get-intrinsic "^1.2.4" +es-define-property@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.1.tgz#983eb2f9a6724e9303f61addf011c72e09e0b0fa" + integrity sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g== + es-errors@^1.2.1, es-errors@^1.3.0: version "1.3.0" resolved "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz" @@ -981,6 +1179,13 @@ es-object-atoms@^1.0.0: dependencies: es-errors "^1.3.0" +es-object-atoms@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz#1c4f2c4837327597ce69d2ca190a7fdd172338c1" + integrity sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA== + dependencies: + es-errors "^1.3.0" + es-set-tostringtag@^2.0.3: version "2.0.3" resolved "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.3.tgz" @@ -990,6 +1195,16 @@ es-set-tostringtag@^2.0.3: has-tostringtag "^1.0.2" hasown "^2.0.1" +es-set-tostringtag@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz#f31dbbe0c183b00a6d26eb6325c810c0fd18bd4d" + integrity sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA== + dependencies: + es-errors "^1.3.0" + get-intrinsic "^1.2.6" + has-tostringtag "^1.0.2" + hasown "^2.0.2" + es-shim-unscopables@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.0.0.tgz" @@ -997,6 +1212,13 @@ es-shim-unscopables@^1.0.0: dependencies: has "^1.0.3" +es-shim-unscopables@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz#438df35520dac5d105f3943d927549ea3b00f4b5" + integrity sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw== + dependencies: + hasown "^2.0.2" + es-to-primitive@^1.2.1: version "1.2.1" resolved "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz" @@ -1006,6 +1228,15 @@ es-to-primitive@^1.2.1: is-date-object "^1.0.1" is-symbol "^1.0.2" +es-to-primitive@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/es-to-primitive/-/es-to-primitive-1.3.0.tgz#96c89c82cc49fd8794a24835ba3e1ff87f214e18" + integrity sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g== + dependencies: + is-callable "^1.2.7" + is-date-object "^1.0.5" + is-symbol "^1.0.4" + es6-promise@^4.0.3: version "4.2.8" resolved "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz" @@ -1047,106 +1278,110 @@ eslint-import-resolver-node@^0.3.9: is-core-module "^2.13.0" resolve "^1.22.4" -eslint-module-utils@^2.8.0: - version "2.8.0" - resolved "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.8.0.tgz" - integrity sha512-aWajIYfsqCKRDgUfjEXNN/JlrzauMuSEy5sbd7WXbtW3EH6A6MpwEh42c7qD+MqQo9QMJ6fWLAeIJynx0g6OAw== +eslint-module-utils@^2.12.0: + version "2.12.1" + resolved "https://registry.yarnpkg.com/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz#f76d3220bfb83c057651359295ab5854eaad75ff" + integrity sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw== dependencies: debug "^3.2.7" -eslint-plugin-import@^2.23.4, eslint-plugin-import@^2.29.0: - version "2.29.0" - resolved "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.29.0.tgz" - integrity sha512-QPOO5NO6Odv5lpoTkddtutccQjysJuFxoPS7fAHO+9m9udNHvTCPSAMW9zGAYj8lAIdr40I8yPCdUYrncXtrwg== +eslint-plugin-import@^2.31.0: + version "2.31.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.31.0.tgz#310ce7e720ca1d9c0bb3f69adfd1c6bdd7d9e0e7" + integrity sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A== dependencies: - array-includes "^3.1.7" - array.prototype.findlastindex "^1.2.3" + "@rtsao/scc" "^1.1.0" + array-includes "^3.1.8" + array.prototype.findlastindex "^1.2.5" array.prototype.flat "^1.3.2" array.prototype.flatmap "^1.3.2" debug "^3.2.7" doctrine "^2.1.0" eslint-import-resolver-node "^0.3.9" - eslint-module-utils "^2.8.0" - hasown "^2.0.0" - is-core-module "^2.13.1" + eslint-module-utils "^2.12.0" + hasown "^2.0.2" + is-core-module "^2.15.1" is-glob "^4.0.3" minimatch "^3.1.2" - object.fromentries "^2.0.7" - object.groupby "^1.0.1" - object.values "^1.1.7" + object.fromentries "^2.0.8" + object.groupby "^1.0.3" + object.values "^1.2.0" semver "^6.3.1" - tsconfig-paths "^3.14.2" + string.prototype.trimend "^1.0.8" + tsconfig-paths "^3.15.0" -eslint-scope@^7.2.2: - version "7.2.2" - resolved "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz" - integrity sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg== +eslint-scope@^8.3.0: + version "8.4.0" + resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-8.4.0.tgz#88e646a207fad61436ffa39eb505147200655c82" + integrity sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg== dependencies: esrecurse "^4.3.0" estraverse "^5.2.0" -eslint-visitor-keys@^3.3.0, eslint-visitor-keys@^3.4.1, eslint-visitor-keys@^3.4.3: +eslint-visitor-keys@^3.3.0: version "3.4.3" resolved "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz" integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag== -"eslint@^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8", eslint@^4.19.1, "eslint@^6.0.0 || ^7.0.0 || >=8.0.0", eslint@^8.40.0: - version "8.57.1" - resolved "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz" - integrity sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA== +eslint-visitor-keys@^4.2.0, eslint-visitor-keys@^4.2.1: + version "4.2.1" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz#4cfea60fe7dd0ad8e816e1ed026c1d5251b512c1" + integrity sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ== + +eslint@^9.24.0: + version "9.24.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-9.24.0.tgz#9a7f2e6cb2de81c405ab244b02f4584c79dc6bee" + integrity sha512-eh/jxIEJyZrvbWRe4XuVclLPDYSYYYgLy5zXGGxD6j8zjSAxFEzI2fL/8xNq6O2yKqVt+eF2YhV+hxjV6UKXwQ== dependencies: "@eslint-community/eslint-utils" "^4.2.0" - "@eslint-community/regexpp" "^4.6.1" - "@eslint/eslintrc" "^2.1.4" - "@eslint/js" "8.57.1" - "@humanwhocodes/config-array" "^0.13.0" + "@eslint-community/regexpp" "^4.12.1" + "@eslint/config-array" "^0.20.0" + "@eslint/config-helpers" "^0.2.0" + "@eslint/core" "^0.12.0" + "@eslint/eslintrc" "^3.3.1" + "@eslint/js" "9.24.0" + "@eslint/plugin-kit" "^0.2.7" + "@humanfs/node" "^0.16.6" "@humanwhocodes/module-importer" "^1.0.1" - "@nodelib/fs.walk" "^1.2.8" - "@ungap/structured-clone" "^1.2.0" + "@humanwhocodes/retry" "^0.4.2" + "@types/estree" "^1.0.6" + "@types/json-schema" "^7.0.15" ajv "^6.12.4" chalk "^4.0.0" - cross-spawn "^7.0.2" + cross-spawn "^7.0.6" debug "^4.3.2" - doctrine "^3.0.0" escape-string-regexp "^4.0.0" - eslint-scope "^7.2.2" - eslint-visitor-keys "^3.4.3" - espree "^9.6.1" - esquery "^1.4.2" + eslint-scope "^8.3.0" + eslint-visitor-keys "^4.2.0" + espree "^10.3.0" + esquery "^1.5.0" esutils "^2.0.2" fast-deep-equal "^3.1.3" - file-entry-cache "^6.0.1" + file-entry-cache "^8.0.0" find-up "^5.0.0" glob-parent "^6.0.2" - globals "^13.19.0" - graphemer "^1.4.0" ignore "^5.2.0" imurmurhash "^0.1.4" is-glob "^4.0.0" - is-path-inside "^3.0.3" - js-yaml "^4.1.0" json-stable-stringify-without-jsonify "^1.0.1" - levn "^0.4.1" lodash.merge "^4.6.2" minimatch "^3.1.2" natural-compare "^1.4.0" optionator "^0.9.3" - strip-ansi "^6.0.1" - text-table "^0.2.0" -espree@^9.6.0, espree@^9.6.1: - version "9.6.1" - resolved "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz" - integrity sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ== +espree@^10.0.1, espree@^10.3.0: + version "10.4.0" + resolved "https://registry.yarnpkg.com/espree/-/espree-10.4.0.tgz#d54f4949d4629005a1fa168d937c3ff1f7e2a837" + integrity sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ== dependencies: - acorn "^8.9.0" + acorn "^8.15.0" acorn-jsx "^5.3.2" - eslint-visitor-keys "^3.4.1" + eslint-visitor-keys "^4.2.1" -esquery@^1.4.2: - version "1.5.0" - resolved "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz" - integrity sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg== +esquery@^1.5.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.6.0.tgz#91419234f804d852a82dceec3e16cdc22cf9dae7" + integrity sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg== dependencies: estraverse "^5.1.0" @@ -1187,7 +1422,7 @@ extend@^3.0.0, extend@~3.0.2: resolved "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz" integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== -extsprintf@^1.2.0, extsprintf@1.3.0: +extsprintf@1.3.0, extsprintf@^1.2.0: version "1.3.0" resolved "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz" integrity sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g== @@ -1207,19 +1442,12 @@ fast-levenshtein@^2.0.6: resolved "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz" integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== -fastq@^1.6.0: - version "1.17.1" - resolved "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz" - integrity sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w== - dependencies: - reusify "^1.0.4" - -file-entry-cache@^6.0.1: - version "6.0.1" - resolved "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz" - integrity sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg== +file-entry-cache@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz#7787bddcf1131bffb92636c69457bbc0edd6d81f" + integrity sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ== dependencies: - flat-cache "^3.0.4" + flat-cache "^4.0.0" fill-range@^7.0.1: version "7.0.1" @@ -1249,19 +1477,24 @@ find-up@^5.0.0: locate-path "^6.0.0" path-exists "^4.0.0" -flat-cache@^3.0.4: - version "3.0.4" - resolved "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz" - integrity sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg== +flat-cache@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-4.0.1.tgz#0ece39fcb14ee012f4b0410bd33dd9c1f011127c" + integrity sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw== dependencies: - flatted "^3.1.0" - rimraf "^3.0.2" + flatted "^3.2.9" + keyv "^4.5.4" -flatted@^3.1.0, flatted@^3.2.7: +flatted@^3.2.7: version "3.3.1" resolved "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz" integrity sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw== +flatted@^3.2.9: + version "3.3.3" + resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.3.3.tgz#67c8fad95454a7c7abebf74bb78ee74a44023358" + integrity sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg== + follow-redirects@^1.0.0: version "1.15.2" resolved "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz" @@ -1274,6 +1507,13 @@ for-each@^0.3.3: dependencies: is-callable "^1.1.3" +for-each@^0.3.5: + version "0.3.5" + resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.5.tgz#d650688027826920feeb0af747ee7b9421a41d47" + integrity sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg== + dependencies: + is-callable "^1.2.7" + forever-agent@~0.6.1: version "0.6.1" resolved "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz" @@ -1314,12 +1554,12 @@ fs.realpath@^1.0.0: resolved "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz" integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== -function-bind@^1.1.1: - version "1.1.1" - resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz" - integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A== +fsevents@~2.3.2: + version "2.3.3" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" + integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== -function-bind@^1.1.2: +function-bind@^1.1.1, function-bind@^1.1.2: version "1.1.2" resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz" integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== @@ -1334,6 +1574,18 @@ function.prototype.name@^1.1.6: es-abstract "^1.22.1" functions-have-names "^1.2.3" +function.prototype.name@^1.1.8: + version "1.1.8" + resolved "https://registry.yarnpkg.com/function.prototype.name/-/function.prototype.name-1.1.8.tgz#e68e1df7b259a5c949eeef95cdbde53edffabb78" + integrity sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.3" + define-properties "^1.2.1" + functions-have-names "^1.2.3" + hasown "^2.0.2" + is-callable "^1.2.7" + functions-have-names@^1.2.3: version "1.2.3" resolved "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz" @@ -1344,37 +1596,7 @@ get-caller-file@^2.0.5: resolved "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz" integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== -get-intrinsic@^1.0.2: - version "1.2.1" - resolved "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.1.tgz" - integrity sha512-2DcsyfABl+gVHEfCOaTrWgyt+tb6MSEGmKq+kI5HwLbIYgjgmMcV8KQ41uaKz1xxUcn9tJtgFbQUEVcEbd0FYw== - dependencies: - function-bind "^1.1.1" - has "^1.0.3" - has-proto "^1.0.1" - has-symbols "^1.0.3" - -get-intrinsic@^1.1.1: - version "1.2.1" - resolved "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.1.tgz" - integrity sha512-2DcsyfABl+gVHEfCOaTrWgyt+tb6MSEGmKq+kI5HwLbIYgjgmMcV8KQ41uaKz1xxUcn9tJtgFbQUEVcEbd0FYw== - dependencies: - function-bind "^1.1.1" - has "^1.0.3" - has-proto "^1.0.1" - has-symbols "^1.0.3" - -get-intrinsic@^1.1.3: - version "1.2.1" - resolved "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.1.tgz" - integrity sha512-2DcsyfABl+gVHEfCOaTrWgyt+tb6MSEGmKq+kI5HwLbIYgjgmMcV8KQ41uaKz1xxUcn9tJtgFbQUEVcEbd0FYw== - dependencies: - function-bind "^1.1.1" - has "^1.0.3" - has-proto "^1.0.1" - has-symbols "^1.0.3" - -get-intrinsic@^1.2.1, get-intrinsic@^1.2.3, get-intrinsic@^1.2.4: +get-intrinsic@^1.1.3, get-intrinsic@^1.2.1, get-intrinsic@^1.2.3, get-intrinsic@^1.2.4: version "1.2.4" resolved "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz" integrity sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ== @@ -1385,6 +1607,30 @@ get-intrinsic@^1.2.1, get-intrinsic@^1.2.3, get-intrinsic@^1.2.4: has-symbols "^1.0.3" hasown "^2.0.0" +get-intrinsic@^1.2.5, get-intrinsic@^1.2.6, get-intrinsic@^1.2.7, get-intrinsic@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01" + integrity sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ== + dependencies: + call-bind-apply-helpers "^1.0.2" + es-define-property "^1.0.1" + es-errors "^1.3.0" + es-object-atoms "^1.1.1" + function-bind "^1.1.2" + get-proto "^1.0.1" + gopd "^1.2.0" + has-symbols "^1.1.0" + hasown "^2.0.2" + math-intrinsics "^1.1.0" + +get-proto@^1.0.0, get-proto@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/get-proto/-/get-proto-1.0.1.tgz#150b3f2743869ef3e851ec0c49d15b1d14d00ee1" + integrity sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g== + dependencies: + dunder-proto "^1.0.1" + es-object-atoms "^1.0.0" + get-symbol-description@^1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.2.tgz" @@ -1394,6 +1640,15 @@ get-symbol-description@^1.0.2: es-errors "^1.3.0" get-intrinsic "^1.2.4" +get-symbol-description@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/get-symbol-description/-/get-symbol-description-1.1.0.tgz#7bdd54e0befe8ffc9f3b4e203220d9f1e881b6ee" + integrity sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg== + dependencies: + call-bound "^1.0.3" + es-errors "^1.3.0" + get-intrinsic "^1.2.6" + getpass@^0.1.1: version "0.1.7" resolved "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz" @@ -1427,12 +1682,10 @@ glob@^7.1.3, glob@^7.1.4, glob@^7.1.6, glob@^7.1.7: once "^1.3.0" path-is-absolute "^1.0.0" -globals@^13.19.0: - version "13.24.0" - resolved "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz" - integrity sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ== - dependencies: - type-fest "^0.20.2" +globals@^14.0.0: + version "14.0.0" + resolved "https://registry.yarnpkg.com/globals/-/globals-14.0.0.tgz#898d7413c29babcf6bafe56fcadded858ada724e" + integrity sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ== globalthis@^1.0.3: version "1.0.3" @@ -1441,6 +1694,14 @@ globalthis@^1.0.3: dependencies: define-properties "^1.1.3" +globalthis@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/globalthis/-/globalthis-1.0.4.tgz#7430ed3a975d97bfb59bcce41f5cabbafa651236" + integrity sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ== + dependencies: + define-properties "^1.2.1" + gopd "^1.0.1" + globalyzer@0.1.0: version "0.1.0" resolved "https://registry.npmjs.org/globalyzer/-/globalyzer-0.1.0.tgz" @@ -1458,16 +1719,16 @@ gopd@^1.0.1: dependencies: get-intrinsic "^1.1.3" +gopd@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1" + integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg== + graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.6: version "4.2.11" resolved "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz" integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== -graphemer@^1.4.0: - version "1.4.0" - resolved "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz" - integrity sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag== - har-schema@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz" @@ -1496,35 +1757,35 @@ has-flag@^4.0.0: resolved "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz" integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== -has-property-descriptors@^1.0.0: - version "1.0.0" - resolved "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.0.tgz" - integrity sha512-62DVLZGoiEBDHQyqG4w9xCuZ7eJEwNmJRWw2VY84Oedb7WFcA27fiEVe8oUQx9hAUJ4ekurquucTGwsyO1XGdQ== - dependencies: - get-intrinsic "^1.1.1" - -has-property-descriptors@^1.0.2: +has-property-descriptors@^1.0.0, has-property-descriptors@^1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz" integrity sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg== dependencies: es-define-property "^1.0.0" -has-proto@^1.0.1: - version "1.0.1" - resolved "https://registry.npmjs.org/has-proto/-/has-proto-1.0.1.tgz" - integrity sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg== - -has-proto@^1.0.3: +has-proto@^1.0.1, has-proto@^1.0.3: version "1.0.3" resolved "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz" integrity sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q== +has-proto@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/has-proto/-/has-proto-1.2.0.tgz#5de5a6eabd95fdffd9818b43055e8065e39fe9d5" + integrity sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ== + dependencies: + dunder-proto "^1.0.0" + has-symbols@^1.0.2, has-symbols@^1.0.3: version "1.0.3" resolved "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz" integrity sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A== +has-symbols@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.1.0.tgz#fc9c6a783a084951d0b971fe1018de813707a338" + integrity sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ== + has-tostringtag@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz" @@ -1636,7 +1897,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.3, inherits@2, inherits@2.0.4: +inherits@2, inherits@2.0.4, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.3: version "2.0.4" resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -1650,6 +1911,15 @@ internal-slot@^1.0.7: hasown "^2.0.0" side-channel "^1.0.4" +internal-slot@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.1.0.tgz#1eac91762947d2f7056bc838d93e13b2e9604961" + integrity sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw== + dependencies: + es-errors "^1.3.0" + hasown "^2.0.2" + side-channel "^1.1.0" + is-array-buffer@^3.0.4: version "3.0.4" resolved "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.4.tgz" @@ -1658,6 +1928,26 @@ is-array-buffer@^3.0.4: call-bind "^1.0.2" get-intrinsic "^1.2.1" +is-array-buffer@^3.0.5: + version "3.0.5" + resolved "https://registry.yarnpkg.com/is-array-buffer/-/is-array-buffer-3.0.5.tgz#65742e1e687bd2cc666253068fd8707fe4d44280" + integrity sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.3" + get-intrinsic "^1.2.6" + +is-async-function@^2.0.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/is-async-function/-/is-async-function-2.1.1.tgz#3e69018c8e04e73b738793d020bfe884b9fd3523" + integrity sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ== + dependencies: + async-function "^1.0.0" + call-bound "^1.0.3" + get-proto "^1.0.1" + has-tostringtag "^1.0.2" + safe-regex-test "^1.1.0" + is-bigint@^1.0.1: version "1.0.4" resolved "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz" @@ -1665,6 +1955,13 @@ is-bigint@^1.0.1: dependencies: has-bigints "^1.0.1" +is-bigint@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-bigint/-/is-bigint-1.1.0.tgz#dda7a3445df57a42583db4228682eba7c4170672" + integrity sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ== + dependencies: + has-bigints "^1.0.2" + is-binary-path@~2.1.0: version "2.1.0" resolved "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz" @@ -1680,25 +1977,33 @@ is-boolean-object@^1.1.0: call-bind "^1.0.2" has-tostringtag "^1.0.0" +is-boolean-object@^1.2.1: + version "1.2.2" + resolved "https://registry.yarnpkg.com/is-boolean-object/-/is-boolean-object-1.2.2.tgz#7067f47709809a393c71ff5bb3e135d8a9215d9e" + integrity sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A== + dependencies: + call-bound "^1.0.3" + has-tostringtag "^1.0.2" + is-callable@^1.1.3, is-callable@^1.1.4, is-callable@^1.2.7: version "1.2.7" resolved "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz" integrity sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA== -is-core-module@^2.11.0: - version "2.12.1" - resolved "https://registry.npmjs.org/is-core-module/-/is-core-module-2.12.1.tgz" - integrity sha512-Q4ZuBAe2FUsKtyQJoQHlvP8OvBERxO3jEmy1I7hcRXcJBGGHFh/aJBswbXuS9sgrDH2QUO8ilkwNPHvHMd8clg== - dependencies: - has "^1.0.3" - -is-core-module@^2.13.0, is-core-module@^2.13.1: +is-core-module@^2.11.0, is-core-module@^2.13.0: version "2.13.1" resolved "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz" integrity sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw== dependencies: hasown "^2.0.0" +is-core-module@^2.15.1: + version "2.16.1" + resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.16.1.tgz#2a98801a849f43e2add644fbb6bc6229b19a4ef4" + integrity sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w== + dependencies: + hasown "^2.0.2" + is-data-view@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.1.tgz" @@ -1706,6 +2011,15 @@ is-data-view@^1.0.1: dependencies: is-typed-array "^1.1.13" +is-data-view@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-data-view/-/is-data-view-1.0.2.tgz#bae0a41b9688986c2188dda6657e56b8f9e63b8e" + integrity sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw== + dependencies: + call-bound "^1.0.2" + get-intrinsic "^1.2.6" + is-typed-array "^1.1.13" + is-date-object@^1.0.1: version "1.0.5" resolved "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz" @@ -1713,16 +2027,41 @@ is-date-object@^1.0.1: dependencies: has-tostringtag "^1.0.0" +is-date-object@^1.0.5, is-date-object@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.1.0.tgz#ad85541996fc7aa8b2729701d27b7319f95d82f7" + integrity sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg== + dependencies: + call-bound "^1.0.2" + has-tostringtag "^1.0.2" + is-extglob@^2.1.1: version "2.1.1" resolved "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz" integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== +is-finalizationregistry@^1.1.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz#eefdcdc6c94ddd0674d9c85887bf93f944a97c90" + integrity sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg== + dependencies: + call-bound "^1.0.3" + is-fullwidth-code-point@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz" integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== +is-generator-function@^1.0.10: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-generator-function/-/is-generator-function-1.1.0.tgz#bf3eeda931201394f57b5dba2800f91a238309ca" + integrity sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ== + dependencies: + call-bound "^1.0.3" + get-proto "^1.0.0" + has-tostringtag "^1.0.2" + safe-regex-test "^1.1.0" + is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1: version "4.0.3" resolved "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz" @@ -1730,6 +2069,11 @@ is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1: dependencies: is-extglob "^2.1.1" +is-map@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/is-map/-/is-map-2.0.3.tgz#ede96b7fe1e270b3c4465e3a465658764926d62e" + integrity sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw== + is-module@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz" @@ -1747,16 +2091,19 @@ is-number-object@^1.0.4: dependencies: has-tostringtag "^1.0.0" +is-number-object@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/is-number-object/-/is-number-object-1.1.1.tgz#144b21e95a1bc148205dcc2814a9134ec41b2541" + integrity sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw== + dependencies: + call-bound "^1.0.3" + has-tostringtag "^1.0.2" + is-number@^7.0.0: version "7.0.0" resolved "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz" integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== -is-path-inside@^3.0.3: - version "3.0.3" - resolved "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz" - integrity sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ== - is-reference@^1.2.1: version "1.2.1" resolved "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz" @@ -1772,20 +2119,35 @@ is-regex@^1.1.4: call-bind "^1.0.2" has-tostringtag "^1.0.0" -is-shared-array-buffer@^1.0.2: - version "1.0.2" - resolved "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.2.tgz" - integrity sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA== +is-regex@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.2.1.tgz#76d70a3ed10ef9be48eb577887d74205bf0cad22" + integrity sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g== dependencies: - call-bind "^1.0.2" + call-bound "^1.0.2" + gopd "^1.2.0" + has-tostringtag "^1.0.2" + hasown "^2.0.2" -is-shared-array-buffer@^1.0.3: +is-set@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/is-set/-/is-set-2.0.3.tgz#8ab209ea424608141372ded6e0cb200ef1d9d01d" + integrity sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg== + +is-shared-array-buffer@^1.0.2, is-shared-array-buffer@^1.0.3: version "1.0.3" resolved "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.3.tgz" integrity sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg== dependencies: call-bind "^1.0.7" +is-shared-array-buffer@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz#9b67844bd9b7f246ba0708c3a93e34269c774f6f" + integrity sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A== + dependencies: + call-bound "^1.0.3" + is-string@^1.0.5, is-string@^1.0.7: version "1.0.7" resolved "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz" @@ -1793,6 +2155,14 @@ is-string@^1.0.5, is-string@^1.0.7: dependencies: has-tostringtag "^1.0.0" +is-string@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/is-string/-/is-string-1.1.1.tgz#92ea3f3d5c5b6e039ca8677e5ac8d07ea773cbb9" + integrity sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA== + dependencies: + call-bound "^1.0.3" + has-tostringtag "^1.0.2" + is-symbol@^1.0.2, is-symbol@^1.0.3: version "1.0.4" resolved "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz" @@ -1800,6 +2170,15 @@ is-symbol@^1.0.2, is-symbol@^1.0.3: dependencies: has-symbols "^1.0.2" +is-symbol@^1.0.4, is-symbol@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.1.1.tgz#f47761279f532e2b05a7024a7506dbbedacd0634" + integrity sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w== + dependencies: + call-bound "^1.0.2" + has-symbols "^1.1.0" + safe-regex-test "^1.1.0" + is-typed-array@^1.1.13: version "1.1.13" resolved "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.13.tgz" @@ -1807,11 +2186,23 @@ is-typed-array@^1.1.13: dependencies: which-typed-array "^1.1.14" +is-typed-array@^1.1.14, is-typed-array@^1.1.15: + version "1.1.15" + resolved "https://registry.yarnpkg.com/is-typed-array/-/is-typed-array-1.1.15.tgz#4bfb4a45b61cee83a5a46fba778e4e8d59c0ce0b" + integrity sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ== + dependencies: + which-typed-array "^1.1.16" + is-typedarray@~1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz" integrity sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA== +is-weakmap@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/is-weakmap/-/is-weakmap-2.0.2.tgz#bf72615d649dfe5f699079c54b83e47d1ae19cfd" + integrity sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w== + is-weakref@^1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz" @@ -1819,6 +2210,21 @@ is-weakref@^1.0.2: dependencies: call-bind "^1.0.2" +is-weakref@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/is-weakref/-/is-weakref-1.1.1.tgz#eea430182be8d64174bd96bffbc46f21bf3f9293" + integrity sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew== + dependencies: + call-bound "^1.0.3" + +is-weakset@^2.0.3: + version "2.0.4" + resolved "https://registry.yarnpkg.com/is-weakset/-/is-weakset-2.0.4.tgz#c9f5deb0bc1906c6d6f1027f284ddf459249daca" + integrity sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ== + dependencies: + call-bound "^1.0.3" + get-intrinsic "^1.2.6" + isarray@^2.0.5: version "2.0.5" resolved "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz" @@ -1870,6 +2276,11 @@ jsbn@~0.1.0: resolved "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz" integrity sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg== +json-buffer@3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.1.tgz#9338802a30d3b6605fbe0613e094008ca8c05a13" + integrity sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ== + json-schema-traverse@^0.4.1: version "0.4.1" resolved "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz" @@ -1937,7 +2348,7 @@ karma-sauce-launcher@^1.2.0: saucelabs "^1.4.0" wd "^1.4.0" -karma@^3.1.1, karma@^6.4.2: +karma@^6.4.2: version "6.4.4" resolved "https://registry.npmjs.org/karma/-/karma-6.4.4.tgz" integrity sha512-LrtUxbdvt1gOpo3gxG+VAJlJAEMhbWlM4YrFQgql98FwF7+K8K12LYO4hnDdUkNjeztYrOXEMqgTajSWgmtI/w== @@ -1967,6 +2378,13 @@ karma@^3.1.1, karma@^6.4.2: ua-parser-js "^0.7.30" yargs "^16.1.1" +keyv@^4.5.4: + version "4.5.4" + resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93" + integrity sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw== + dependencies: + json-buffer "3.0.1" + lazystream@^1.0.0: version "1.0.1" resolved "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz" @@ -2042,6 +2460,11 @@ magic-string@^0.25.7: dependencies: sourcemap-codec "^1.4.8" +math-intrinsics@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9" + integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g== + media-typer@0.3.0: version "0.3.0" resolved "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz" @@ -2069,7 +2492,7 @@ mime@^2.5.2: resolved "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz" integrity sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg== -minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2: +minimatch@^3.0.4, minimatch@^3.1.1, minimatch@^3.1.2: version "3.1.2" resolved "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz" integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== @@ -2095,11 +2518,6 @@ mock-socket@^2.0.0: dependencies: urijs "~1.17.0" -ms@^2.1.1: - version "2.1.3" - resolved "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz" - integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== - ms@2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz" @@ -2110,6 +2528,11 @@ ms@2.1.2: resolved "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== +ms@^2.1.1: + version "2.1.3" + resolved "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + natural-compare@^1.4.0: version "1.4.0" resolved "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz" @@ -2150,6 +2573,11 @@ object-inspect@^1.13.1: resolved "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz" integrity sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ== +object-inspect@^1.13.3, object-inspect@^1.13.4: + version "1.13.4" + resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.4.tgz#8375265e21bc20d0fa582c22e1b13485d6e00213" + integrity sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew== + object-keys@^1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz" @@ -2165,40 +2593,46 @@ object.assign@^4.1.5: has-symbols "^1.0.3" object-keys "^1.1.1" -object.fromentries@^2.0.7: - version "2.0.7" - resolved "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.7.tgz" - integrity sha512-UPbPHML6sL8PI/mOqPwsH4G6iyXcCGzLin8KvEPenOZN5lpCNBZZQ+V62vdjB1mQHrmqGQt5/OJzemUA+KJmEA== +object.assign@^4.1.7: + version "4.1.7" + resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.7.tgz#8c14ca1a424c6a561b0bb2a22f66f5049a945d3d" + integrity sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw== dependencies: - call-bind "^1.0.2" - define-properties "^1.2.0" - es-abstract "^1.22.1" + call-bind "^1.0.8" + call-bound "^1.0.3" + define-properties "^1.2.1" + es-object-atoms "^1.0.0" + has-symbols "^1.1.0" + object-keys "^1.1.1" -object.groupby@^1.0.1: - version "1.0.1" - resolved "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.1.tgz" - integrity sha512-HqaQtqLnp/8Bn4GL16cj+CUYbnpe1bh0TtEaWvybszDG4tgxCJuRpV8VGuvNaI1fAnI4lUJzDG55MXcOH4JZcQ== +object.fromentries@^2.0.8: + version "2.0.8" + resolved "https://registry.yarnpkg.com/object.fromentries/-/object.fromentries-2.0.8.tgz#f7195d8a9b97bd95cbc1999ea939ecd1a2b00c65" + integrity sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ== dependencies: - call-bind "^1.0.2" - define-properties "^1.2.0" - es-abstract "^1.22.1" - get-intrinsic "^1.2.1" + call-bind "^1.0.7" + define-properties "^1.2.1" + es-abstract "^1.23.2" + es-object-atoms "^1.0.0" -object.values@^1.1.7: - version "1.1.7" - resolved "https://registry.npmjs.org/object.values/-/object.values-1.1.7.tgz" - integrity sha512-aU6xnDFYT3x17e/f0IiiwlGPTy2jzMySGfUB4fq6z7CV8l85CWHDk5ErhyhpfDHhrOMwGFhSQkhMGHaIotA6Ng== +object.groupby@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/object.groupby/-/object.groupby-1.0.3.tgz#9b125c36238129f6f7b61954a1e7176148d5002e" + integrity sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ== dependencies: - call-bind "^1.0.2" - define-properties "^1.2.0" - es-abstract "^1.22.1" + call-bind "^1.0.7" + define-properties "^1.2.1" + es-abstract "^1.23.2" -on-finished@~2.3.0: - version "2.3.0" - resolved "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz" - integrity sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww== +object.values@^1.2.0: + version "1.2.1" + resolved "https://registry.yarnpkg.com/object.values/-/object.values-1.2.1.tgz#deed520a50809ff7f75a7cfd4bc64c7a038c6216" + integrity sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA== dependencies: - ee-first "1.1.1" + call-bind "^1.0.8" + call-bound "^1.0.3" + define-properties "^1.2.1" + es-object-atoms "^1.0.0" on-finished@2.4.1: version "2.4.1" @@ -2207,6 +2641,13 @@ on-finished@2.4.1: dependencies: ee-first "1.1.1" +on-finished@~2.3.0: + version "2.3.0" + resolved "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz" + integrity sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww== + dependencies: + ee-first "1.1.1" + once@^1.3.0, once@^1.4.0: version "1.4.0" resolved "https://registry.npmjs.org/once/-/once-1.4.0.tgz" @@ -2226,6 +2667,15 @@ optionator@^0.9.3: type-check "^0.4.0" word-wrap "^1.2.5" +own-keys@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/own-keys/-/own-keys-1.0.1.tgz#e4006910a2bf913585289676eebd6f390cf51358" + integrity sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg== + dependencies: + get-intrinsic "^1.2.6" + object-keys "^1.1.1" + safe-push-apply "^1.0.0" + p-limit@^3.0.2: version "3.1.0" resolved "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz" @@ -2277,6 +2727,11 @@ performance-now@^2.1.0: resolved "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz" integrity sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow== +picocolors@^1.0.0: + version "1.1.1" + resolved "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz" + integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== + picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.2: version "2.3.1" resolved "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz" @@ -2322,11 +2777,6 @@ qjobs@^1.2.0: resolved "https://registry.npmjs.org/qjobs/-/qjobs-1.2.0.tgz" integrity sha512-8YOJEHtxpySA3fFDyCRxA+UUV+fA+rTWnuWvylOK/NCjhY+b4ocCtmu8TtsWb+mYeU+GCHf/S66KZF/AsteKHg== -qs@~6.5.2: - version "6.5.3" - resolved "https://registry.npmjs.org/qs/-/qs-6.5.3.tgz" - integrity sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA== - qs@6.11.0: version "6.11.0" resolved "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz" @@ -2334,12 +2784,12 @@ qs@6.11.0: dependencies: side-channel "^1.0.4" -queue-microtask@^1.2.2: - version "1.2.3" - resolved "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz" - integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== +qs@~6.5.2: + version "6.5.3" + resolved "https://registry.npmjs.org/qs/-/qs-6.5.3.tgz" + integrity sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA== -qunit@^2.0.0, qunit@^2.8.0: +qunit@^2.8.0: version "2.19.4" resolved "https://registry.npmjs.org/qunit/-/qunit-2.19.4.tgz" integrity sha512-aqUzzUeCqlleWYKlpgfdHHw9C6KxkB9H3wNfiBg5yHqQMzy0xw/pbCRHYFkjl8MsP/t8qkTQE+JTYL71azgiew== @@ -2370,33 +2820,7 @@ raw-body@2.5.2: iconv-lite "0.4.24" unpipe "1.0.0" -readable-stream@^2.0.0: - version "2.3.8" - resolved "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz" - integrity sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA== - dependencies: - core-util-is "~1.0.0" - inherits "~2.0.3" - isarray "~1.0.0" - process-nextick-args "~2.0.0" - safe-buffer "~5.1.1" - string_decoder "~1.1.1" - util-deprecate "~1.0.1" - -readable-stream@^2.0.5: - version "2.3.8" - resolved "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz" - integrity sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA== - dependencies: - core-util-is "~1.0.0" - inherits "~2.0.3" - isarray "~1.0.0" - process-nextick-args "~2.0.0" - safe-buffer "~5.1.1" - string_decoder "~1.1.1" - util-deprecate "~1.0.1" - -readable-stream@^2.3.6: +readable-stream@^2.0.0, readable-stream@^2.0.5, readable-stream@^2.3.6: version "2.3.8" resolved "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz" integrity sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA== @@ -2425,6 +2849,20 @@ readdirp@~3.6.0: dependencies: picomatch "^2.2.1" +reflect.getprototypeof@^1.0.6, reflect.getprototypeof@^1.0.9: + version "1.0.10" + resolved "https://registry.yarnpkg.com/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz#c629219e78a3316d8b604c765ef68996964e7bf9" + integrity sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw== + dependencies: + call-bind "^1.0.8" + define-properties "^1.2.1" + es-abstract "^1.23.9" + es-errors "^1.3.0" + es-object-atoms "^1.0.0" + get-intrinsic "^1.2.7" + get-proto "^1.0.1" + which-builtin-type "^1.2.1" + regexp.prototype.flags@^1.5.2: version "1.5.2" resolved "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.2.tgz" @@ -2435,6 +2873,18 @@ regexp.prototype.flags@^1.5.2: es-errors "^1.3.0" set-function-name "^2.0.1" +regexp.prototype.flags@^1.5.4: + version "1.5.4" + resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz#1ad6c62d44a259007e55b3970e00f746efbcaa19" + integrity sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA== + dependencies: + call-bind "^1.0.8" + define-properties "^1.2.1" + es-errors "^1.3.0" + get-proto "^1.0.1" + gopd "^1.2.0" + set-function-name "^2.0.2" + request@2.88.0: version "2.88.0" resolved "https://registry.npmjs.org/request/-/request-2.88.0.tgz" @@ -2494,15 +2944,10 @@ resolve@^1.22.4: path-parse "^1.0.7" supports-preserve-symlinks-flag "^1.0.0" -reusify@^1.0.4: - version "1.0.4" - resolved "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz" - integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw== - rfdc@^1.3.0: - version "1.3.1" - resolved "https://registry.npmjs.org/rfdc/-/rfdc-1.3.1.tgz" - integrity sha512-r5a3l5HzYlIC68TpmYKlxWjmOP6wiPJ1vWv2HeLhNsRZMrCkxeqxiHlQ21oXmQ4F3SiryXBHhAD7JZqvOJjFmg== + version "1.4.1" + resolved "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz" + integrity sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA== rimraf@^2.5.4: version "2.7.1" @@ -2528,20 +2973,13 @@ rollup-plugin-terser@^7.0.2: serialize-javascript "^4.0.0" terser "^5.0.0" -rollup@^1.20.0||^2.0.0, rollup@^2.0.0, rollup@^2.35.1, rollup@^2.38.3, rollup@^2.53.3: +rollup@^2.35.1: version "2.79.1" resolved "https://registry.npmjs.org/rollup/-/rollup-2.79.1.tgz" integrity sha512-uKxbd0IhMZOhjAiD5oAFp7BqvkA4Dv47qpOCtaNvng4HBwdbWtdOh8f5nZNuk2rp51PMGk3bzfWu5oayNEuYnw== optionalDependencies: fsevents "~2.3.2" -run-parallel@^1.1.9: - version "1.2.0" - resolved "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz" - integrity sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA== - dependencies: - queue-microtask "^1.2.2" - safe-array-concat@^1.1.2: version "1.1.2" resolved "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.2.tgz" @@ -2552,6 +2990,17 @@ safe-array-concat@^1.1.2: has-symbols "^1.0.3" isarray "^2.0.5" +safe-array-concat@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/safe-array-concat/-/safe-array-concat-1.1.3.tgz#c9e54ec4f603b0bbb8e7e5007a5ee7aecd1538c3" + integrity sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.2" + get-intrinsic "^1.2.6" + has-symbols "^1.1.0" + isarray "^2.0.5" + safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.2, safe-buffer@~5.2.0: version "5.2.1" resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz" @@ -2562,6 +3011,14 @@ safe-buffer@~5.1.0, safe-buffer@~5.1.1: resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz" integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== +safe-push-apply@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/safe-push-apply/-/safe-push-apply-1.0.0.tgz#01850e981c1602d398c85081f360e4e6d03d27f5" + integrity sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA== + dependencies: + es-errors "^1.3.0" + isarray "^2.0.5" + safe-regex-test@^1.0.3: version "1.0.3" resolved "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.3.tgz" @@ -2571,7 +3028,16 @@ safe-regex-test@^1.0.3: es-errors "^1.3.0" is-regex "^1.1.4" -safer-buffer@^2.0.2, safer-buffer@^2.1.0, "safer-buffer@>= 2.1.2 < 3", safer-buffer@~2.1.0: +safe-regex-test@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/safe-regex-test/-/safe-regex-test-1.1.0.tgz#7f87dfb67a3150782eaaf18583ff5d1711ac10c1" + integrity sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + is-regex "^1.2.1" + +"safer-buffer@>= 2.1.2 < 3", safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0: version "2.1.2" resolved "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== @@ -2606,7 +3072,7 @@ serialize-javascript@^4.0.0: dependencies: randombytes "^2.1.0" -set-function-length@^1.2.1: +set-function-length@^1.2.1, set-function-length@^1.2.2: version "1.2.2" resolved "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz" integrity sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg== @@ -2618,7 +3084,7 @@ set-function-length@^1.2.1: gopd "^1.0.1" has-property-descriptors "^1.0.2" -set-function-name@^2.0.1: +set-function-name@^2.0.1, set-function-name@^2.0.2: version "2.0.2" resolved "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz" integrity sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ== @@ -2628,6 +3094,15 @@ set-function-name@^2.0.1: functions-have-names "^1.2.3" has-property-descriptors "^1.0.2" +set-proto@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/set-proto/-/set-proto-1.0.0.tgz#0760dbcff30b2d7e801fd6e19983e56da337565e" + integrity sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw== + dependencies: + dunder-proto "^1.0.1" + es-errors "^1.3.0" + es-object-atoms "^1.0.0" + setprototypeof@1.2.0: version "1.2.0" resolved "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz" @@ -2645,6 +3120,35 @@ shebang-regex@^3.0.0: resolved "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz" integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== +side-channel-list@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/side-channel-list/-/side-channel-list-1.0.0.tgz#10cb5984263115d3b7a0e336591e290a830af8ad" + integrity sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA== + dependencies: + es-errors "^1.3.0" + object-inspect "^1.13.3" + +side-channel-map@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/side-channel-map/-/side-channel-map-1.0.1.tgz#d6bb6b37902c6fef5174e5f533fab4c732a26f42" + integrity sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + get-intrinsic "^1.2.5" + object-inspect "^1.13.3" + +side-channel-weakmap@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz#11dda19d5368e40ce9ec2bdc1fb0ecbc0790ecea" + integrity sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + get-intrinsic "^1.2.5" + object-inspect "^1.13.3" + side-channel-map "^1.0.1" + side-channel@^1.0.4: version "1.0.6" resolved "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz" @@ -2655,6 +3159,17 @@ side-channel@^1.0.4: get-intrinsic "^1.2.4" object-inspect "^1.13.1" +side-channel@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.1.0.tgz#c3fcff9c4da932784873335ec9765fa94ff66bc9" + integrity sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw== + dependencies: + es-errors "^1.3.0" + object-inspect "^1.13.3" + side-channel-list "^1.0.0" + side-channel-map "^1.0.1" + side-channel-weakmap "^1.0.2" + socket.io-adapter@~2.5.2: version "2.5.5" resolved "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.5.tgz" @@ -2672,15 +3187,15 @@ socket.io-parser@~4.2.4: debug "~4.3.1" socket.io@^4.7.2: - version "4.7.5" - resolved "https://registry.npmjs.org/socket.io/-/socket.io-4.7.5.tgz" - integrity sha512-DmeAkF6cwM9jSfmp6Dr/5/mfMwb5Z5qRrSXLpo3Fq5SqyU8CMF15jIN4ZhfSwu35ksM1qmHZDQ/DK5XTccSTvA== + version "4.8.0" + resolved "https://registry.npmjs.org/socket.io/-/socket.io-4.8.0.tgz" + integrity sha512-8U6BEgGjQOfGz3HHTYaC/L1GaxDCJ/KM0XTkJly0EhZ5U/du9uNEZy4ZgYzEzIqlx2CMm25CrCqr1ck899eLNA== dependencies: accepts "~1.3.4" base64id "~2.0.0" cors "~2.8.5" debug "~4.3.2" - engine.io "~6.5.2" + engine.io "~6.6.0" socket.io-adapter "~2.5.2" socket.io-parser "~4.2.4" @@ -2722,15 +3237,23 @@ sshpk@^1.7.0: safer-buffer "^2.0.2" tweetnacl "~0.14.0" +statuses@2.0.1: + version "2.0.1" + resolved "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz" + integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ== + statuses@~1.5.0: version "1.5.0" resolved "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz" integrity sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA== -statuses@2.0.1: - version "2.0.1" - resolved "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz" - integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ== +stop-iteration-iterator@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz#f481ff70a548f6124d0312c3aa14cbfa7aa542ad" + integrity sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ== + dependencies: + es-errors "^1.3.0" + internal-slot "^1.1.0" streamroller@^3.1.5: version "3.1.5" @@ -2741,20 +3264,6 @@ streamroller@^3.1.5: debug "^4.3.4" fs-extra "^8.1.0" -string_decoder@^1.1.1: - version "1.3.0" - resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz" - integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== - dependencies: - safe-buffer "~5.2.0" - -string_decoder@~1.1.1: - version "1.1.1" - resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz" - integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg== - dependencies: - safe-buffer "~5.1.0" - string-width@^4.1.0, string-width@^4.2.0: version "4.2.3" resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" @@ -2764,6 +3273,19 @@ string-width@^4.1.0, string-width@^4.2.0: is-fullwidth-code-point "^3.0.0" strip-ansi "^6.0.1" +string.prototype.trim@^1.2.10: + version "1.2.10" + resolved "https://registry.yarnpkg.com/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz#40b2dd5ee94c959b4dcfb1d65ce72e90da480c81" + integrity sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.2" + define-data-property "^1.1.4" + define-properties "^1.2.1" + es-abstract "^1.23.5" + es-object-atoms "^1.0.0" + has-property-descriptors "^1.0.2" + string.prototype.trim@^1.2.9: version "1.2.9" resolved "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.9.tgz" @@ -2783,6 +3305,16 @@ string.prototype.trimend@^1.0.8: define-properties "^1.2.1" es-object-atoms "^1.0.0" +string.prototype.trimend@^1.0.9: + version "1.0.9" + resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz#62e2731272cd285041b36596054e9f66569b6942" + integrity sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.2" + define-properties "^1.2.1" + es-object-atoms "^1.0.0" + string.prototype.trimstart@^1.0.8: version "1.0.8" resolved "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz" @@ -2792,6 +3324,20 @@ string.prototype.trimstart@^1.0.8: define-properties "^1.2.1" es-object-atoms "^1.0.0" +string_decoder@^1.1.1: + version "1.3.0" + resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz" + integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== + dependencies: + safe-buffer "~5.2.0" + +string_decoder@~1.1.1: + version "1.1.1" + resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz" + integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg== + dependencies: + safe-buffer "~5.1.0" + strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" @@ -2840,20 +3386,15 @@ tar-stream@^2.1.0: readable-stream "^3.1.1" terser@^5.0.0: - version "5.18.2" - resolved "https://registry.npmjs.org/terser/-/terser-5.18.2.tgz" - integrity sha512-Ah19JS86ypbJzTzvUCX7KOsEIhDaRONungA4aYBjEP3JZRf4ocuDzTg4QWZnPn9DEMiMYGJPiSOy7aykoCc70w== + version "5.36.0" + resolved "https://registry.npmjs.org/terser/-/terser-5.36.0.tgz" + integrity sha512-IYV9eNMuFAV4THUspIRXkLakHnV6XO7FEdtKjf/mDyrnqUg9LnlOn6/RwRvM9SZjR4GUq8Nk8zj67FzVARr74w== dependencies: "@jridgewell/source-map" "^0.3.3" acorn "^8.8.2" commander "^2.20.0" source-map-support "~0.5.20" -text-table@^0.2.0: - version "0.2.0" - resolved "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz" - integrity sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw== - tiny-glob@0.2.9: version "0.2.9" resolved "https://registry.npmjs.org/tiny-glob/-/tiny-glob-0.2.9.tgz" @@ -2888,14 +3429,14 @@ tough-cookie@~2.4.3: punycode "^1.4.1" trix@^2.0.0: - version "2.0.5" - resolved "https://registry.npmjs.org/trix/-/trix-2.0.5.tgz" - integrity sha512-OiCbDf17F7JahEwhyL1MvK9DxAAT1vkaW5sn+zpwfemZAcc4RfQB4ku18/1mKP58LRwBhjcy+6TBho7ciXz52Q== + version "2.1.7" + resolved "https://registry.npmjs.org/trix/-/trix-2.1.7.tgz" + integrity sha512-RyFmjLJfxP2nuAKqgVqJ40wk4qoYfDQtyi71q6ozkP+X4EOILe+j5ll5g/suvTyMx7BacGszNWzjnx9Vbj17sw== -tsconfig-paths@^3.14.2: - version "3.14.2" - resolved "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.14.2.tgz" - integrity sha512-o/9iXgCYc5L/JxCHPe3Hvh8Q/2xm5Z+p18PESBU6Ff33695QnCHBEjcytY2q19ua7Mbl/DavtBOLq+oG0RCL+g== +tsconfig-paths@^3.15.0: + version "3.15.0" + resolved "https://registry.yarnpkg.com/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz#5299ec605e55b1abb23ec939ef15edaf483070d4" + integrity sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg== dependencies: "@types/json5" "^0.0.29" json5 "^1.0.2" @@ -2921,11 +3462,6 @@ type-check@^0.4.0, type-check@~0.4.0: dependencies: prelude-ls "^1.2.1" -type-fest@^0.20.2: - version "0.20.2" - resolved "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz" - integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ== - type-is@~1.6.18: version "1.6.18" resolved "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz" @@ -2943,6 +3479,15 @@ typed-array-buffer@^1.0.2: es-errors "^1.3.0" is-typed-array "^1.1.13" +typed-array-buffer@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz#a72395450a4869ec033fd549371b47af3a2ee536" + integrity sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw== + dependencies: + call-bound "^1.0.3" + es-errors "^1.3.0" + is-typed-array "^1.1.14" + typed-array-byte-length@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.1.tgz" @@ -2954,6 +3499,17 @@ typed-array-byte-length@^1.0.1: has-proto "^1.0.3" is-typed-array "^1.1.13" +typed-array-byte-length@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz#8407a04f7d78684f3d252aa1a143d2b77b4160ce" + integrity sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg== + dependencies: + call-bind "^1.0.8" + for-each "^0.3.3" + gopd "^1.2.0" + has-proto "^1.2.0" + is-typed-array "^1.1.14" + typed-array-byte-offset@^1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.2.tgz" @@ -2966,6 +3522,19 @@ typed-array-byte-offset@^1.0.2: has-proto "^1.0.3" is-typed-array "^1.1.13" +typed-array-byte-offset@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz#ae3698b8ec91a8ab945016108aef00d5bff12355" + integrity sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ== + dependencies: + available-typed-arrays "^1.0.7" + call-bind "^1.0.8" + for-each "^0.3.3" + gopd "^1.2.0" + has-proto "^1.2.0" + is-typed-array "^1.1.15" + reflect.getprototypeof "^1.0.9" + typed-array-length@^1.0.6: version "1.0.6" resolved "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.6.tgz" @@ -2978,6 +3547,18 @@ typed-array-length@^1.0.6: is-typed-array "^1.1.13" possible-typed-array-names "^1.0.0" +typed-array-length@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/typed-array-length/-/typed-array-length-1.0.7.tgz#ee4deff984b64be1e118b0de8c9c877d5ce73d3d" + integrity sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg== + dependencies: + call-bind "^1.0.7" + for-each "^0.3.3" + gopd "^1.0.1" + is-typed-array "^1.1.13" + possible-typed-array-names "^1.0.0" + reflect.getprototypeof "^1.0.6" + ua-parser-js@^0.7.30: version "0.7.39" resolved "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.39.tgz" @@ -2993,12 +3574,27 @@ unbox-primitive@^1.0.2: has-symbols "^1.0.3" which-boxed-primitive "^1.0.2" +unbox-primitive@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.1.0.tgz#8d9d2c9edeea8460c7f35033a88867944934d1e2" + integrity sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw== + dependencies: + call-bound "^1.0.3" + has-bigints "^1.0.2" + has-symbols "^1.1.0" + which-boxed-primitive "^1.1.1" + +undici-types@~6.19.2: + version "6.19.8" + resolved "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz" + integrity sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw== + universalify@^0.1.0: version "0.1.2" resolved "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz" integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg== -unpipe@~1.0.0, unpipe@1.0.0: +unpipe@1.0.0, unpipe@~1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz" integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ== @@ -3078,6 +3674,46 @@ which-boxed-primitive@^1.0.2: is-string "^1.0.5" is-symbol "^1.0.3" +which-boxed-primitive@^1.1.0, which-boxed-primitive@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz#d76ec27df7fa165f18d5808374a5fe23c29b176e" + integrity sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA== + dependencies: + is-bigint "^1.1.0" + is-boolean-object "^1.2.1" + is-number-object "^1.1.1" + is-string "^1.1.1" + is-symbol "^1.1.1" + +which-builtin-type@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/which-builtin-type/-/which-builtin-type-1.2.1.tgz#89183da1b4907ab089a6b02029cc5d8d6574270e" + integrity sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q== + dependencies: + call-bound "^1.0.2" + function.prototype.name "^1.1.6" + has-tostringtag "^1.0.2" + is-async-function "^2.0.0" + is-date-object "^1.1.0" + is-finalizationregistry "^1.1.0" + is-generator-function "^1.0.10" + is-regex "^1.2.1" + is-weakref "^1.0.2" + isarray "^2.0.5" + which-boxed-primitive "^1.1.0" + which-collection "^1.0.2" + which-typed-array "^1.1.16" + +which-collection@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/which-collection/-/which-collection-1.0.2.tgz#627ef76243920a107e7ce8e96191debe4b16c2a0" + integrity sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw== + dependencies: + is-map "^2.0.3" + is-set "^2.0.3" + is-weakmap "^2.0.2" + is-weakset "^2.0.3" + which-typed-array@^1.1.14, which-typed-array@^1.1.15: version "1.1.15" resolved "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.15.tgz" @@ -3089,6 +3725,19 @@ which-typed-array@^1.1.14, which-typed-array@^1.1.15: gopd "^1.0.1" has-tostringtag "^1.0.2" +which-typed-array@^1.1.16, which-typed-array@^1.1.19: + version "1.1.19" + resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.19.tgz#df03842e870b6b88e117524a4b364b6fc689f956" + integrity sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw== + dependencies: + available-typed-arrays "^1.0.7" + call-bind "^1.0.8" + call-bound "^1.0.4" + for-each "^0.3.5" + get-proto "^1.0.1" + gopd "^1.2.0" + has-tostringtag "^1.0.2" + which@^1.2.1: version "1.3.1" resolved "https://registry.npmjs.org/which/-/which-1.3.1.tgz" From 9ef70d58b9555c701403be7ff677a11811dd7ccc Mon Sep 17 00:00:00 2001 From: Kir Shatrov Date: Tue, 26 Aug 2025 07:14:19 -0700 Subject: [PATCH 0661/1075] Make transaction isolation work smoothly with transactional tests --- .../abstract/database_statements.rb | 16 ++++++ .../abstract/transaction.rb | 9 ++++ .../test/cases/transaction_isolation_test.rb | 52 +++++++++++++++++++ 3 files changed, 77 insertions(+) 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 030d3a68113e9..47dc5588dca6a 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb @@ -353,6 +353,22 @@ def truncate_tables(*table_names) # :nodoc: # isolation level. # :args: (requires_new: nil, isolation: nil, &block) def transaction(requires_new: nil, isolation: nil, joinable: true, &block) + # If we're running inside the single, non-joinable transaction that + # ActiveRecord::TestFixtures starts around each example (depth == 1), + # an `isolation:` hint must be validated then ignored so that the + # adapter isn't asked to change the isolation level mid-transaction. + if isolation && !requires_new && open_transactions == 1 && !current_transaction.joinable? + iso = isolation.to_sym + + unless transaction_isolation_levels.include?(iso) + raise ActiveRecord::TransactionIsolationError, + "invalid transaction isolation level: #{iso.inspect}" + end + + current_transaction.isolation = iso + isolation = nil + end + if !requires_new && current_transaction.joinable? if isolation && current_transaction.isolation != isolation raise ActiveRecord::TransactionIsolationError, "cannot set isolation when joining a transaction" diff --git a/activerecord/lib/active_record/connection_adapters/abstract/transaction.rb b/activerecord/lib/active_record/connection_adapters/abstract/transaction.rb index 3262d231a95f3..9235e9a83b300 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/transaction.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/transaction.rb @@ -124,6 +124,7 @@ def before_commit; yield; end def after_commit; yield; end def after_rollback; end def user_transaction; ActiveRecord::Transaction::NULL_TRANSACTION; end + def isolation=(_); end end class Transaction # :nodoc: @@ -156,6 +157,10 @@ def isolation @isolation_level end + def isolation=(isolation) # :nodoc: + @isolation_level = isolation + end + def initialize(connection, isolation: nil, joinable: true, run_commit_callbacks: false) super() @connection = connection @@ -426,6 +431,10 @@ def isolation @parent_transaction.isolation end + def isolation=(isolation) # :nodoc: + @parent_transaction.isolation = isolation + end + def materialize! connection.create_savepoint(savepoint_name) super diff --git a/activerecord/test/cases/transaction_isolation_test.rb b/activerecord/test/cases/transaction_isolation_test.rb index 70b1a82482e6b..cac8319b5c888 100644 --- a/activerecord/test/cases/transaction_isolation_test.rb +++ b/activerecord/test/cases/transaction_isolation_test.rb @@ -302,3 +302,55 @@ def assert_begin_isolation_level_event(events, isolation: "READ COMMITTED", coun end end end + +class TransactionIsolationWithTransactionalTestsTest < ActiveRecord::TestCase + if ActiveRecord::Base.lease_connection.supports_transaction_isolation? && !current_adapter?(:SQLite3Adapter) + class Tag < ActiveRecord::Base + self.table_name = "tags" + end + + test "starting a transaction with isolation does not raise an error" do + assert_nothing_raised do + Tag.transaction(isolation: :read_committed) do + Tag.create! + end + end + end + + test "starting a transaction with isolation sets the isolation level" do + Tag.transaction(isolation: :read_committed) do + assert_equal :read_committed, Tag.lease_connection.current_transaction.isolation + end + end + + test "starting a transaction with a different isolation level raises an error" do + Tag.transaction(isolation: :read_committed) do + Tag.create! + + assert_raises(ActiveRecord::TransactionIsolationError) do + Tag.transaction(isolation: :repeatable_read) do + Tag.create! + end + end + end + end + + test "specifying the same isolation level does not raise an error" do + assert_nothing_raised do + Tag.transaction(isolation: :read_committed) do + Tag.create! + + Tag.transaction(isolation: :read_committed) do + Tag.create! + end + end + end + end + + test "invalid isolation level raises TransactionIsolationError" do + assert_raises(ActiveRecord::TransactionIsolationError) do + Tag.transaction(isolation: :unknown_level) { Tag.create! } + end + end + end +end From 4e03953777daf2cb74467b4b52750347bc3af568 Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Tue, 23 Sep 2025 11:12:02 +0200 Subject: [PATCH 0662/1075] Constantize transaction_isolation_levels --- .../abstract/database_statements.rb | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) 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 47dc5588dca6a..0b68b8a61946e 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb @@ -436,13 +436,16 @@ def begin_deferred_transaction(isolation_level = nil) # :nodoc: end end + TRANSACTION_ISOLATION_LEVELS = { + read_uncommitted: "READ UNCOMMITTED", + read_committed: "READ COMMITTED", + repeatable_read: "REPEATABLE READ", + serializable: "SERIALIZABLE" + }.freeze + private_constant :TRANSACTION_ISOLATION_LEVELS + def transaction_isolation_levels - { - read_uncommitted: "READ UNCOMMITTED", - read_committed: "READ COMMITTED", - repeatable_read: "REPEATABLE READ", - serializable: "SERIALIZABLE" - } + TRANSACTION_ISOLATION_LEVELS end # Begins the transaction with the isolation level set. Raises an error by From 0e8f6893df3361115a732a0905af7e06113baf61 Mon Sep 17 00:00:00 2001 From: Hartley McGuire Date: Tue, 23 Sep 2025 10:20:09 -0400 Subject: [PATCH 0663/1075] Temp point to sdoc stable to fix doc-preview --- Gemfile | 2 +- Gemfile.lock | 12 +++++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/Gemfile b/Gemfile index fbb6bddec6912..27da76d013503 100644 --- a/Gemfile +++ b/Gemfile @@ -62,7 +62,7 @@ group :mdl do end group :doc do - gem "sdoc" + gem "sdoc", github: "rails/sdoc", branch: "stable" gem "redcarpet", "~> 3.6.1", platforms: :ruby gem "w3c_validators", "~> 1.3.6" gem "rouge" diff --git a/Gemfile.lock b/Gemfile.lock index 4e4621af9b45a..bc05e68652bd0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,3 +1,11 @@ +GIT + remote: https://github.com/rails/sdoc.git + revision: ead9de6de9330a489ca1b6e012d4384d11f90750 + branch: stable + specs: + sdoc (2.6.2) + rdoc (>= 5.0) + PATH remote: . specs: @@ -584,8 +592,6 @@ GEM google-protobuf (~> 4.29) sass-embedded (1.83.4-x86_64-linux-musl) google-protobuf (~> 4.29) - sdoc (2.6.2) - rdoc (>= 5.0) securerandom (0.4.1) selenium-webdriver (4.32.0) base64 (~> 0.2) @@ -807,7 +813,7 @@ DEPENDENCIES rubocop-rails rubocop-rails-omakase rubyzip (~> 2.0) - sdoc + sdoc! selenium-webdriver (>= 4.20.0) sidekiq sneakers From da4098ea537ab3e6bfaefebfa5651dd183cb01a6 Mon Sep 17 00:00:00 2001 From: Hartley McGuire Date: Tue, 23 Sep 2025 10:08:22 -0400 Subject: [PATCH 0664/1075] Restore add_default_name_and_id method While private, it appears to be used a lot by other gems --- actionview/lib/action_view/helpers/tags/base.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/actionview/lib/action_view/helpers/tags/base.rb b/actionview/lib/action_view/helpers/tags/base.rb index 8917f80c13663..86f413760a72d 100644 --- a/actionview/lib/action_view/helpers/tags/base.rb +++ b/actionview/lib/action_view/helpers/tags/base.rb @@ -92,6 +92,7 @@ def add_default_name_and_field_for_value(tag_value, options, field = "id") end end end + alias_method :add_default_name_and_id_for_value, :add_default_name_and_field_for_value def add_default_name_and_field(options, field = "id") index = name_and_id_index(options) @@ -104,6 +105,7 @@ def add_default_name_and_field(options, field = "id") end end end + alias_method :add_default_name_and_id, :add_default_name_and_field def tag_name(multiple = false, index = nil) @template_object.field_name(@object_name, sanitized_method_name, multiple: multiple, index: index) From 307b62c8417207bed423dcb8370f2f9187af2e9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Tue, 23 Sep 2025 16:25:18 +0000 Subject: [PATCH 0665/1075] Use released sdoc gem instead of GitHub version --- Gemfile | 2 +- Gemfile.lock | 12 +++--------- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/Gemfile b/Gemfile index 27da76d013503..fbb6bddec6912 100644 --- a/Gemfile +++ b/Gemfile @@ -62,7 +62,7 @@ group :mdl do end group :doc do - gem "sdoc", github: "rails/sdoc", branch: "stable" + gem "sdoc" gem "redcarpet", "~> 3.6.1", platforms: :ruby gem "w3c_validators", "~> 1.3.6" gem "rouge" diff --git a/Gemfile.lock b/Gemfile.lock index bc05e68652bd0..e040b057afdca 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,11 +1,3 @@ -GIT - remote: https://github.com/rails/sdoc.git - revision: ead9de6de9330a489ca1b6e012d4384d11f90750 - branch: stable - specs: - sdoc (2.6.2) - rdoc (>= 5.0) - PATH remote: . specs: @@ -592,6 +584,8 @@ GEM google-protobuf (~> 4.29) sass-embedded (1.83.4-x86_64-linux-musl) google-protobuf (~> 4.29) + sdoc (2.6.3) + rdoc (>= 5.0) securerandom (0.4.1) selenium-webdriver (4.32.0) base64 (~> 0.2) @@ -813,7 +807,7 @@ DEPENDENCIES rubocop-rails rubocop-rails-omakase rubyzip (~> 2.0) - sdoc! + sdoc selenium-webdriver (>= 4.20.0) sidekiq sneakers From b8eb11e9852659ca3f33287c86e88e9a5f819ef3 Mon Sep 17 00:00:00 2001 From: Adrianna Chang Date: Mon, 11 Aug 2025 16:04:35 -0400 Subject: [PATCH 0666/1075] Add ActiveSupport::StructuredEventSubscriber class --- activesupport/CHANGELOG.md | 14 ++++ activesupport/lib/active_support.rb | 1 + .../structured_event_subscriber.rb | 70 ++++++++++++++++++ .../test/structured_event_subscriber_test.rb | 73 +++++++++++++++++++ 4 files changed, 158 insertions(+) create mode 100644 activesupport/lib/active_support/structured_event_subscriber.rb create mode 100644 activesupport/test/structured_event_subscriber_test.rb diff --git a/activesupport/CHANGELOG.md b/activesupport/CHANGELOG.md index d11614b14a724..26de97f2850c5 100644 --- a/activesupport/CHANGELOG.md +++ b/activesupport/CHANGELOG.md @@ -1,3 +1,17 @@ +* Add `ActiveSupport::StructuredEventSubscriber` for consuming notifications and + emitting structured event logs. Events may be emitted with the `#emit_event` + or `#emit_debug_event` methods. + + ```ruby + class MyStructuredEventSubscriber < ActiveSupport::StructuredEventSubscriber + def notification(event) + emit_event("my.notification", data: 1) + end + end + ``` + + *Adrianna Chang* + * `ActiveSupport::FileUpdateChecker` does not depend on `Time.now` to prevent unecessary reloads with time travel test helpers *Jan Grodowski* diff --git a/activesupport/lib/active_support.rb b/activesupport/lib/active_support.rb index f8ed8a835b364..b5939247b9344 100644 --- a/activesupport/lib/active_support.rb +++ b/activesupport/lib/active_support.rb @@ -53,6 +53,7 @@ module ActiveSupport autoload :EventedFileUpdateChecker autoload :ForkTracker autoload :LogSubscriber + autoload :StructuredEventSubscriber autoload :IsolatedExecutionState autoload :Notifications autoload :Reloader diff --git a/activesupport/lib/active_support/structured_event_subscriber.rb b/activesupport/lib/active_support/structured_event_subscriber.rb new file mode 100644 index 0000000000000..5fcb4266cf9b6 --- /dev/null +++ b/activesupport/lib/active_support/structured_event_subscriber.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require "active_support/subscriber" + +module ActiveSupport + # = Active Support Structured Event \Subscriber + # + # +ActiveSupport::StructuredEventSubscriber+ consumes ActiveSupport::Notifications + # in order to emit structured events via +Rails.event+. + # + # An example would be the Action Controller structured event subscriber, responsible for + # emitting request processing events: + # + # module ActionController + # class StructuredEventSubscriber < ActiveSupport::StructuredEventSubscriber + # attach_to :action_controller + # + # def start_processing(event) + # emit_event("controller.request_started", + # controller: event.payload[:controller], + # action: event.payload[:action], + # format: event.payload[:format] + # ) + # end + # end + # end + # + # After configured, whenever a "start_processing.action_controller" notification is published, + # it will properly dispatch the event (+ActiveSupport::Notifications::Event+) to the +start_processing+ method. + # The subscriber can then emit a structured event via the +emit_event+ method. + class StructuredEventSubscriber < Subscriber + # Emit a structured event via Rails.event.notify. + # + # ==== Arguments + # + # * +name+ - The event name as a string or symbol + # * +payload+ - The event payload as a hash or object + # * +caller_depth+ - Stack depth for source location (default: 1) + # * +kwargs+ - Additional payload data merged with the payload hash + def emit_event(name, payload = nil, caller_depth: 1, **kwargs) + ActiveSupport.event_reporter.notify(name, payload, caller_depth: caller_depth + 1, **kwargs) + rescue => e + handle_event_error(name, e) + end + + # Like +emit_event+, but only emits when the event reporter is in debug mode + def emit_debug_event(name, payload = nil, caller_depth: 1, **kwargs) + ActiveSupport.event_reporter.debug(name, payload, caller_depth: caller_depth + 1, **kwargs) + rescue => e + handle_event_error(name, e) + end + + def call(event) + super + rescue => e + handle_event_error(event.name, e) + end + + def publish_event(event) + super + rescue => e + handle_event_error(event.name, e) + end + + private + def handle_event_error(name, error) + ActiveSupport.error_reporter.report(error, source: name) + end + end +end diff --git a/activesupport/test/structured_event_subscriber_test.rb b/activesupport/test/structured_event_subscriber_test.rb new file mode 100644 index 0000000000000..10c828ecd3398 --- /dev/null +++ b/activesupport/test/structured_event_subscriber_test.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require_relative "abstract_unit" +require "active_support/testing/event_reporter_assertions" + +class StructuredEventSubscriberTest < ActiveSupport::TestCase + include ActiveSupport::Testing::EventReporterAssertions + + def setup + @subscriber = ActiveSupport::StructuredEventSubscriber.new + end + + def test_emit_event_calls_event_reporter_notify + event = assert_event_reported("test.event", payload: { key: "value" }) do + @subscriber.emit_event("test.event", { key: "value" }) + end + + assert_equal "test.event", event[:name] + assert_equal({ key: "value" }, event[:payload]) + end + + def test_emit_debug_event_calls_event_reporter_debug + ActiveSupport.event_reporter.with_debug do + assert_event_reported("test.debug", payload: { debug: "info" }) do + @subscriber.emit_debug_event("test.debug", { debug: "info" }) + end + end + end + + def test_emit_event_handles_errors + ActiveSupport.event_reporter.stub(:notify, proc { raise StandardError, "event error" }) do + error_report = assert_error_reported(StandardError) do + @subscriber.emit_event("test.error") + end + assert_equal "test.error", error_report.source + assert_equal "event error", error_report.error.message + end + end + + def test_emit_debug_event_handles_errors + ActiveSupport.event_reporter.stub(:debug, proc { raise StandardError, "debug error" }) do + error_report = assert_error_reported(StandardError) do + @subscriber.emit_debug_event("test.debug_error") + end + assert_equal "test.debug_error", error_report.source + assert_equal "debug error", error_report.error.message + end + end + + def test_call_handles_errors + ActiveSupport::StructuredEventSubscriber.attach_to :test, @subscriber + + event = ActiveSupport::Notifications::Event.new("error_event.test", Time.current, Time.current, "123", {}) + + error_report = assert_error_reported(NoMethodError) do + @subscriber.call(event) + end + assert_match(/undefined method (`|')error_event'/, error_report.error.message) + assert_equal "error_event.test", error_report.source + end + + def test_publish_event_handles_errors + ActiveSupport::StructuredEventSubscriber.attach_to :test, @subscriber + + event = ActiveSupport::Notifications::Event.new("error_event.test", Time.current, Time.current, "123", {}) + + error_report = assert_error_reported(NoMethodError) do + @subscriber.publish_event(event) + end + assert_match(/undefined method (`|')error_event'/, error_report.error.message) + assert_equal "error_event.test", error_report.source + end +end From d6cb07bf333256bc1e536cb3d3f42991da7aa41c Mon Sep 17 00:00:00 2001 From: Adrianna Chang Date: Mon, 11 Aug 2025 16:04:55 -0400 Subject: [PATCH 0667/1075] Structured events for Action Pack --- actionpack/CHANGELOG.md | 14 ++ actionpack/lib/action_controller/api.rb | 1 + actionpack/lib/action_controller/base.rb | 1 + .../structured_event_subscriber.rb | 103 +++++++++++ actionpack/lib/action_dispatch/railtie.rb | 1 + .../structured_event_subscriber.rb | 19 ++ .../structured_event_subscriber_test.rb | 175 ++++++++++++++++++ .../structured_event_subscriber_test.rb | 50 +++++ 8 files changed, 364 insertions(+) create mode 100644 actionpack/lib/action_controller/structured_event_subscriber.rb create mode 100644 actionpack/lib/action_dispatch/structured_event_subscriber.rb create mode 100644 actionpack/test/controller/structured_event_subscriber_test.rb create mode 100644 actionpack/test/dispatch/structured_event_subscriber_test.rb diff --git a/actionpack/CHANGELOG.md b/actionpack/CHANGELOG.md index e10e0a7173936..d67e6c75994c5 100644 --- a/actionpack/CHANGELOG.md +++ b/actionpack/CHANGELOG.md @@ -1,3 +1,17 @@ +* Add structured events for Action Pack and Action Dispatch: + - `action_dispatch.redirect` + - `action_controller.request_started` + - `action_controller.request_completed` + - `action_controller.callback_halted` + - `action_controller.rescue_from_handled` + - `action_controller.file_sent` + - `action_controller.redirected` + - `action_controller.data_sent` + - `action_controller.unpermitted_parameters` + - `action_controller.fragment_cache` + + *Adrianna Chang* + * URL helpers for engines mounted at the application root handle `SCRIPT_NAME` correctly. Fixed an issue where `SCRIPT_NAME` is not applied to paths generated for routes in an engine diff --git a/actionpack/lib/action_controller/api.rb b/actionpack/lib/action_controller/api.rb index b52430467b13f..a44aa2232611c 100644 --- a/actionpack/lib/action_controller/api.rb +++ b/actionpack/lib/action_controller/api.rb @@ -5,6 +5,7 @@ require "action_view" require "action_controller" require "action_controller/log_subscriber" +require "action_controller/structured_event_subscriber" module ActionController # # Action Controller API diff --git a/actionpack/lib/action_controller/base.rb b/actionpack/lib/action_controller/base.rb index 279dbdd71ecb2..99e080b0c5d88 100644 --- a/actionpack/lib/action_controller/base.rb +++ b/actionpack/lib/action_controller/base.rb @@ -4,6 +4,7 @@ require "action_view" require "action_controller/log_subscriber" +require "action_controller/structured_event_subscriber" require "action_controller/metal/params_wrapper" module ActionController diff --git a/actionpack/lib/action_controller/structured_event_subscriber.rb b/actionpack/lib/action_controller/structured_event_subscriber.rb new file mode 100644 index 0000000000000..04ebfa58e5b92 --- /dev/null +++ b/actionpack/lib/action_controller/structured_event_subscriber.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +module ActionController + class StructuredEventSubscriber < ActiveSupport::StructuredEventSubscriber # :nodoc: + INTERNAL_PARAMS = %w(controller action format _method only_path) + + def start_processing(event) + payload = event.payload + params = {} + payload[:params].each_pair do |k, v| + params[k] = v unless INTERNAL_PARAMS.include?(k) + end + format = payload[:format] + format = format.to_s.upcase if format.is_a?(Symbol) + format = "*/*" if format.nil? + + emit_event("action_controller.request_started", + controller: payload[:controller], + action: payload[:action], + format:, + params:, + ) + end + + def process_action(event) + payload = event.payload + status = payload[:status] + + if status.nil? && (exception_class_name = payload[:exception]&.first) + status = ActionDispatch::ExceptionWrapper.status_code_for_exception(exception_class_name) + end + + emit_event("action_controller.request_completed", { + controller: payload[:controller], + action: payload[:action], + status: status, + duration_ms: event.duration.round(2), + gc_time_ms: event.gc_time.round(1), + }.compact) + end + + def halted_callback(event) + emit_event("action_controller.callback_halted", filter: event.payload[:filter]) + end + + def rescue_from_callback(event) + exception = event.payload[:exception] + emit_event("action_controller.rescue_from_handled", + exception_class: exception.class.name, + exception_message: exception.message, + exception_backtrace: exception.backtrace&.first&.delete_prefix("#{Rails.root}/") + ) + end + + def send_file(event) + emit_event("action_controller.file_sent", path: event.payload[:path], duration_ms: event.duration.round(1)) + end + + def redirect_to(event) + emit_event("action_controller.redirected", location: event.payload[:location]) + end + + def send_data(event) + emit_event("action_controller.data_sent", filename: event.payload[:filename], duration_ms: event.duration.round(1)) + end + + def unpermitted_parameters(event) + unpermitted_keys = event.payload[:keys] + context = event.payload[:context] + + params = {} + context[:params].each_pair do |k, v| + params[k] = v unless INTERNAL_PARAMS.include?(k) + end + + emit_debug_event("action_controller.unpermitted_parameters", + controller: context[:controller], + action: context[:action], + unpermitted_keys:, + params: + ) + end + + %w(write_fragment read_fragment exist_fragment? expire_fragment).each do |method| + class_eval <<-METHOD, __FILE__, __LINE__ + 1 + # frozen_string_literal: true + def #{method}(event) + return unless ActionController::Base.enable_fragment_cache_logging + + key = ActiveSupport::Cache.expand_cache_key(event.payload[:key] || event.payload[:path]) + + emit_event("action_controller.fragment_cache", + method: "#{method}", + key: key, + duration_ms: event.duration.round(1) + ) + end + METHOD + end + end +end + +ActionController::StructuredEventSubscriber.attach_to :action_controller diff --git a/actionpack/lib/action_dispatch/railtie.rb b/actionpack/lib/action_dispatch/railtie.rb index 1ad32b7c64e82..20a775d53cdfb 100644 --- a/actionpack/lib/action_dispatch/railtie.rb +++ b/actionpack/lib/action_dispatch/railtie.rb @@ -4,6 +4,7 @@ require "action_dispatch" require "action_dispatch/log_subscriber" +require "action_dispatch/structured_event_subscriber" require "active_support/messages/rotation_configuration" module ActionDispatch diff --git a/actionpack/lib/action_dispatch/structured_event_subscriber.rb b/actionpack/lib/action_dispatch/structured_event_subscriber.rb new file mode 100644 index 0000000000000..12c536a0d1c70 --- /dev/null +++ b/actionpack/lib/action_dispatch/structured_event_subscriber.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module ActionDispatch + class StructuredEventSubscriber < ActiveSupport::StructuredEventSubscriber # :nodoc: + def redirect(event) + payload = event.payload + status = payload[:status] + + emit_event("action_dispatch.redirect", { + location: payload[:location], + status: status, + status_name: Rack::Utils::HTTP_STATUS_CODES[status], + duration_ms: event.duration.round(2) + }) + end + end +end + +ActionDispatch::StructuredEventSubscriber.attach_to :action_dispatch diff --git a/actionpack/test/controller/structured_event_subscriber_test.rb b/actionpack/test/controller/structured_event_subscriber_test.rb new file mode 100644 index 0000000000000..9bcca9293a87b --- /dev/null +++ b/actionpack/test/controller/structured_event_subscriber_test.rb @@ -0,0 +1,175 @@ +# frozen_string_literal: true + +require "abstract_unit" +require "active_support/testing/event_reporter_assertions" +require "action_controller/structured_event_subscriber" + +module ActionController + class StructuredEventSubscriberTest < ActionController::TestCase + module Another + class StructuredEventSubscribersController < ActionController::Base + rescue_from StandardError do |exception| + head 500 + end + + def show + head :ok + end + + def redirector + redirect_to "http://foo.bar/" + end + + def data_sender + send_data "cool data", filename: "file.txt" + end + + def file_sender + send_file File.expand_path("company.rb", FIXTURE_LOAD_PATH) + end + + def filterable_redirector + redirect_to "http://secret.foo.bar/" + end + + def unpermitted_parameters + params.permit(:name) + render plain: "OK" + end + + def with_fragment_cache + render inline: "<%= cache('foo'){ 'bar' } %>" + end + + def raise_error + raise StandardError, "Something went wrong" + end + end + end + + tests Another::StructuredEventSubscribersController + + include ActiveSupport::Testing::EventReporterAssertions + + def setup + @original_action_on_unpermitted_parameters = ActionController::Parameters.action_on_unpermitted_parameters + ActionController::Parameters.action_on_unpermitted_parameters = :log + end + + def teardown + ActionController::Parameters.action_on_unpermitted_parameters = @original_action_on_unpermitted_parameters + end + + def test_start_processing + assert_event_reported("action_controller.request_started", payload: { + controller: Another::StructuredEventSubscribersController.name, + action: "show", + format: "HTML" + }) do + get :show + end + end + + def test_start_processing_as_json + assert_event_reported("action_controller.request_started", payload: { + controller: Another::StructuredEventSubscribersController.name, + action: "show", + format: "JSON" + }) do + get :show, format: "json" + end + end + + def test_start_processing_with_parameters + assert_event_reported("action_controller.request_started", payload: { + controller: Another::StructuredEventSubscribersController.name, + action: "show", + params: { "id" => "10" } + }) do + get :show, params: { id: "10" } + end + end + + def test_process_action + event = assert_event_reported("action_controller.request_completed", payload: { + controller: Another::StructuredEventSubscribersController.name, + action: "show", + status: 200, + }) do + get :show + end + + assert event[:payload][:duration_ms].is_a?(Numeric) + end + + def test_redirect + assert_event_reported("action_controller.redirected", payload: { location: "http://foo.bar/" }) do + get :redirector + end + end + + def test_send_data + event = assert_event_reported("action_controller.data_sent", payload: { + filename: "file.txt" + }) do + get :data_sender + end + + assert event[:payload][:duration_ms].is_a?(Numeric) + end + + def test_send_file + event = assert_event_reported("action_controller.file_sent", payload: { + path: /company\.rb/ + }) do + get :file_sender + end + + assert event[:payload][:duration_ms].is_a?(Numeric) + end + + def test_unpermitted_parameters + ActiveSupport.event_reporter.with_debug do + assert_event_reported("action_controller.unpermitted_parameters", payload: { + controller: Another::StructuredEventSubscribersController.name, + action: "unpermitted_parameters", + unpermitted_keys: ["age"], + params: { "name" => "John", "age" => "30" } + }) do + post :unpermitted_parameters, params: { name: "John", age: 30 } + end + end + end + + def test_rescue_from_callback + assert_event_reported("action_controller.rescue_from_handled", payload: { + exception_class: "StandardError", + exception_message: "Something went wrong" + }) do + get :raise_error + end + end + + def test_fragment_cache + original_enable_fragment_cache_logging = ActionController::Base.enable_fragment_cache_logging + ActionController::Base.enable_fragment_cache_logging = true + cache_path = Dir.mktmpdir(%w[tmp cache]) + @controller.cache_store = :file_store, cache_path + + assert_event_reported("action_controller.fragment_cache", payload: { + method: "read_fragment", + key: "views/foo" + }) do + assert_event_reported("action_controller.fragment_cache", payload: { + method: "write_fragment", + key: "views/foo" + }) do + get :with_fragment_cache + end + end + ensure + ActionController::Base.enable_fragment_cache_logging = original_enable_fragment_cache_logging + FileUtils.rm_rf(cache_path) + end + end +end diff --git a/actionpack/test/dispatch/structured_event_subscriber_test.rb b/actionpack/test/dispatch/structured_event_subscriber_test.rb new file mode 100644 index 0000000000000..5e115d7008022 --- /dev/null +++ b/actionpack/test/dispatch/structured_event_subscriber_test.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require "abstract_unit" +require "active_support/testing/event_reporter_assertions" +require "action_dispatch/structured_event_subscriber" + +module ActionDispatch + class StructuredEventSubscriberTest < ActionDispatch::IntegrationTest + include ActiveSupport::Testing::EventReporterAssertions + + test "redirect is reported as structured event" do + draw do + get "redirect", to: redirect("/login") + end + + event = assert_event_reported("action_dispatch.redirect", payload: { + location: "http://www.example.com/login", + status: 301, + status_name: "Moved Permanently" + }) do + get "/redirect" + end + + assert event[:payload][:duration_ms].is_a?(Numeric) + end + + test "redirect with custom status is reported correctly" do + draw do + get "redirect", to: redirect("/moved", status: 302) + end + + assert_event_reported("action_dispatch.redirect", payload: { + location: "http://www.example.com/moved", + status: 302, + status_name: "Found" + }) do + get "/redirect" + end + end + + private + def draw(&block) + self.class.stub_controllers do |routes| + routes.default_url_options = { host: "www.example.com" } + routes.draw(&block) + @app = RoutedRackApp.new routes + end + end + end +end From 1a25283bb156a1fe9aeb822a03d1872bc49a8e11 Mon Sep 17 00:00:00 2001 From: Adrianna Chang Date: Mon, 11 Aug 2025 16:05:09 -0400 Subject: [PATCH 0668/1075] Structured events for Active Job --- activejob/CHANGELOG.md | 23 ++ activejob/lib/active_job/base.rb | 1 + .../active_job/structured_event_subscriber.rb | 261 +++++++++++++ .../cases/structured_event_subscriber_test.rb | 355 ++++++++++++++++++ 4 files changed, 640 insertions(+) create mode 100644 activejob/lib/active_job/structured_event_subscriber.rb create mode 100644 activejob/test/cases/structured_event_subscriber_test.rb diff --git a/activejob/CHANGELOG.md b/activejob/CHANGELOG.md index 7ec15f56d3d5a..c52a7e119fee2 100644 --- a/activejob/CHANGELOG.md +++ b/activejob/CHANGELOG.md @@ -1,3 +1,26 @@ +* Add structured events for Active Job: + - `active_job.enqueue_failed` + - `active_job.enqueue_aborted` + - `active_job.enqueued` + - `active_job.bulk_enqueued` + - `active_job.started` + - `active_job.failed` + - `active_job.aborted` + - `active_job.completed` + - `active_job.retry_scheduled` + - `active_job.retry_stopped` + - `active_job.discarded` + - `active_job.interrupt` + - `active_job.resume` + - `active_job.step_skipped` + - `active_job.step_resumed` + - `active_job.step_started` + - `active_job.step_interrupted` + - `active_job.step_errored` + - `active_job.step` + + *Adrianna Chang* + ## Rails 8.1.0.beta1 (September 04, 2025) ## * Deprecate built-in `sidekiq` adapter. diff --git a/activejob/lib/active_job/base.rb b/activejob/lib/active_job/base.rb index c494fe8ee9ea8..01d5d25832e70 100644 --- a/activejob/lib/active_job/base.rb +++ b/activejob/lib/active_job/base.rb @@ -9,6 +9,7 @@ require "active_job/callbacks" require "active_job/exceptions" require "active_job/log_subscriber" +require "active_job/structured_event_subscriber" require "active_job/logging" require "active_job/instrumentation" require "active_job/execution_state" diff --git a/activejob/lib/active_job/structured_event_subscriber.rb b/activejob/lib/active_job/structured_event_subscriber.rb new file mode 100644 index 0000000000000..ea748c6e5f34d --- /dev/null +++ b/activejob/lib/active_job/structured_event_subscriber.rb @@ -0,0 +1,261 @@ +# frozen_string_literal: true + +require "active_support/structured_event_subscriber" + +module ActiveJob + class StructuredEventSubscriber < ActiveSupport::StructuredEventSubscriber # :nodoc: + def enqueue(event) + job = event.payload[:job] + ex = event.payload[:exception_object] || job.enqueue_error + + if ex + emit_event("active_job.enqueue_failed", + job_class: job.class.name, + job_id: job.job_id, + queue: job.queue_name, + exception_class: ex.class.name, + exception_message: ex.message + ) + elsif event.payload[:aborted] + emit_event("active_job.enqueue_aborted", + job_class: job.class.name, + job_id: job.job_id, + queue: job.queue_name + ) + else + payload = { + job_class: job.class.name, + job_id: job.job_id, + queue: job.queue_name, + } + if job.class.log_arguments? + payload[:arguments] = job.arguments + end + emit_event("active_job.enqueued", payload) + end + end + + def enqueue_at(event) + job = event.payload[:job] + ex = event.payload[:exception_object] || job.enqueue_error + + if ex + emit_event("active_job.enqueue_failed", + job_class: job.class.name, + job_id: job.job_id, + queue: job.queue_name, + scheduled_at: job.scheduled_at, + exception_class: ex.class.name, + exception_message: ex.message + ) + elsif event.payload[:aborted] + emit_event("active_job.enqueue_aborted", + job_class: job.class.name, + job_id: job.job_id, + queue: job.queue_name, + scheduled_at: job.scheduled_at + ) + else + payload = { + job_class: job.class.name, + job_id: job.job_id, + queue: job.queue_name, + scheduled_at: job.scheduled_at, + } + if job.class.log_arguments? + payload[:arguments] = job.arguments + end + emit_event("active_job.enqueued", payload) + end + end + + def enqueue_all(event) + jobs = event.payload[:jobs] + adapter = event.payload[:adapter] + enqueued_count = event.payload[:enqueued_count].to_i + failed_count = jobs.size - enqueued_count + + emit_event("active_job.bulk_enqueued", + adapter: ActiveJob.adapter_name(adapter), + total_jobs: jobs.size, + enqueued_count: enqueued_count, + failed_count: failed_count, + job_classes: jobs.map { |job| job.class.name }.tally + ) + end + + def perform_start(event) + job = event.payload[:job] + payload = { + job_class: job.class.name, + job_id: job.job_id, + queue: job.queue_name, + enqueued_at: job.enqueued_at&.utc&.iso8601(9), + } + if job.class.log_arguments? + payload[:arguments] = job.arguments + end + emit_event("active_job.started", payload) + end + + def perform(event) + job = event.payload[:job] + ex = event.payload[:exception_object] + + if ex + emit_event("active_job.failed", + job_class: job.class.name, + job_id: job.job_id, + queue: job.queue_name, + duration_ms: event.duration.round(2), + exception_class: ex.class.name, + exception_message: ex.message + ) + elsif event.payload[:aborted] + emit_event("active_job.aborted", + job_class: job.class.name, + job_id: job.job_id, + queue: job.queue_name, + duration_ms: event.duration.round(2) + ) + else + emit_event("active_job.completed", + job_class: job.class.name, + job_id: job.job_id, + queue: job.queue_name, + duration_ms: event.duration.round(2) + ) + end + end + + def enqueue_retry(event) + job = event.payload[:job] + ex = event.payload[:error] + wait = event.payload[:wait] + + emit_event("active_job.retry_scheduled", + job_class: job.class.name, + job_id: job.job_id, + executions: job.executions, + wait_seconds: wait.to_i, + exception_class: ex&.class&.name, + exception_message: ex&.message + ) + end + + def retry_stopped(event) + job = event.payload[:job] + ex = event.payload[:error] + + emit_event("active_job.retry_stopped", + job_class: job.class.name, + job_id: job.job_id, + executions: job.executions, + exception_class: ex.class.name, + exception_message: ex.message + ) + end + + def discard(event) + job = event.payload[:job] + ex = event.payload[:error] + + emit_event("active_job.discarded", + job_class: job.class.name, + job_id: job.job_id, + exception_class: ex.class.name, + exception_message: ex.message + ) + end + + def interrupt(event) + job = event.payload[:job] + description = event.payload[:description] + reason = event.payload[:reason] + + emit_event("active_job.interrupt", + job_class: job.class.name, + job_id: job.job_id, + description: description, + reason: reason, + ) + end + + def resume(event) + job = event.payload[:job] + description = event.payload[:description] + + emit_event("active_job.resume", + job_class: job.class.name, + job_id: job.job_id, + description: description, + ) + end + + def step_skipped(event) + job = event.payload[:job] + step = event.payload[:step] + + emit_event("active_job.step_skipped", + job_class: job.class.name, + job_id: job.job_id, + step: step.name, + ) + end + + def step_started(event) + job = event.payload[:job] + step = event.payload[:step] + + if step.resumed? + emit_event("active_job.step_resumed", + job_class: job.class.name, + job_id: job.job_id, + step: step.name, + cursor: step.cursor, + ) + else + emit_event("active_job.step_started", + job_class: job.class.name, + job_id: job.job_id, + step: step.name, + ) + end + end + + def step(event) + job = event.payload[:job] + step = event.payload[:step] + ex = event.payload[:exception_object] + + if event.payload[:interrupted] + emit_event("active_job.step_interrupted", + job_class: job.class.name, + job_id: job.job_id, + step: step.name, + cursor: step.cursor, + duration: event.duration.round(2), + ) + elsif ex + emit_event("active_job.step_errored", + job_class: job.class.name, + job_id: job.job_id, + step: step.name, + cursor: step.cursor, + duration: event.duration.round(2), + exception_class: ex.class.name, + exception_message: ex.message, + ) + else + emit_event("active_job.step", + job_class: job.class.name, + job_id: job.job_id, + step: step.name, + duration: event.duration.round(2), + ) + end + end + end +end + +ActiveJob::StructuredEventSubscriber.attach_to :active_job diff --git a/activejob/test/cases/structured_event_subscriber_test.rb b/activejob/test/cases/structured_event_subscriber_test.rb new file mode 100644 index 0000000000000..b0848df31a27c --- /dev/null +++ b/activejob/test/cases/structured_event_subscriber_test.rb @@ -0,0 +1,355 @@ +# frozen_string_literal: true + +require "helper" +require "active_support/testing/event_reporter_assertions" +require "active_job/structured_event_subscriber" +require "active_job/continuable" + +module ActiveJob + class StructuredEventSubscriberTest < ActiveSupport::TestCase + include ActiveSupport::Testing::EventReporterAssertions + + class TestJob < ActiveJob::Base + def perform(arg = nil) + case arg + when "raise_error" + raise StandardError, "Something went wrong" + when "discard_error" + raise StandardError, "Discard this job" + end + end + end + + class RetryJob < ActiveJob::Base + retry_on StandardError, wait: 1.second, attempts: 3 + + def perform + raise StandardError, "Retry me" + end + end + + class DiscardJob < ActiveJob::Base + discard_on StandardError + + def perform + raise StandardError, "Discard me" + end + end + + class ContinuationJob < ActiveJob::Base + include ActiveJob::Continuable + + def perform(action:) + case action + when :interrupt + interrupt!(reason: "Interrupted") if executions == 1 + when :step + step :step do + end + when :interrupt_step + step :interrupt_step do + interrupt!(reason: "Interrupted") if resumptions.zero? + end + when :error_step + step :error_step do + raise "Error" + end + when :resume + self.continuation = ActiveJob::Continuation.new(self, "completed" => ["step"]) + continue { } + when :skip_step + self.continuation = ActiveJob::Continuation.new(self, "completed" => ["step"]) + step :step do + end + when :resume_step + self.continuation = ActiveJob::Continuation.new(self, "completed" => [], "current" => ["step", { "job" => self, "resumed" => true }]) + step :step do + end + end + end + end + + def test_enqueue_job + event = assert_event_reported("active_job.enqueued", payload: { + job_class: TestJob.name, + queue: "default" + }) do + TestJob.perform_later + end + + payload = event[:payload] + assert payload[:job_id].present? + assert_empty payload[:arguments] + end + + def test_enqueue_job_with_arguments + assert_event_reported("active_job.enqueued", payload: { + job_class: TestJob.name, + queue: "default", + arguments: ["test_arg"] + }) do + TestJob.perform_later("test_arg") + end + end + + def test_enqueue_job_with_arguments_with_log_arguments_false + TestJob.log_arguments = false + event = assert_event_reported("active_job.enqueued", payload: { + job_class: TestJob.name, + queue: "default", + }) do + TestJob.perform_later("test_arg") + end + + assert_not event[:payload].key?(:arguments) + ensure + TestJob.log_arguments = true + end + + unless adapter_is?(:inline, :sneakers) + def test_enqueue_at_job + scheduled_time = 1.hour.from_now + + event = assert_event_reported("active_job.enqueued", payload: { + job_class: TestJob.name, + queue: "default" + }) do + TestJob.set(wait_until: scheduled_time).perform_later + end + + assert event[:payload][:job_id].present? + assert event[:payload][:scheduled_at].present? + end + end + + def test_perform_start_job + event = assert_event_reported("active_job.started", payload: { + job_class: TestJob.name, + queue: "default" + }) do + TestJob.perform_now + end + + assert event[:payload][:job_id].present? + end + + def test_perform_completed_job + event = assert_event_reported("active_job.completed", payload: { + job_class: TestJob.name, + queue: "default" + }) do + TestJob.perform_now + end + + assert event[:payload][:job_id].present? + assert event[:payload][:duration_ms].is_a?(Numeric) + end + + def test_perform_failed_job + event = assert_event_reported("active_job.failed", payload: { + job_class: TestJob.name, + queue: "default", + exception_class: "StandardError", + exception_message: "Something went wrong" + }) do + assert_raises(StandardError) do + TestJob.perform_now("raise_error") + end + end + + assert event[:payload][:job_id].present? + assert event[:payload][:duration_ms].is_a?(Numeric) + end + + def test_enqueue_failed_job + failing_enqueue_job_class = Class.new(TestJob) do + before_enqueue do + raise StandardError, "Enqueue failed" + end + end + + assert_event_reported("active_job.enqueue_failed", payload: { + job_class: failing_enqueue_job_class.name, + queue: "default", + exception_class: "StandardError", + exception_message: "Enqueue failed" + }) do + assert_raises(StandardError) do + failing_enqueue_job_class.perform_later + end + end + end + + def test_enqueue_aborted_job + aborting_enqueue_job_class = Class.new(TestJob) do + before_enqueue do + throw :abort + end + end + + assert_event_reported("active_job.enqueue_aborted", payload: { + job_class: aborting_enqueue_job_class.name, + queue: "default" + }) do + aborting_enqueue_job_class.perform_later + end + end + + def test_perform_aborted_job + aborting_perform_job_class = Class.new(TestJob) do + before_perform do + throw :abort + end + end + + assert_event_reported("active_job.aborted", payload: { + job_class: aborting_perform_job_class.name, + queue: "default" + }) do + aborting_perform_job_class.perform_now + end + end + + unless adapter_is?(:inline, :sneakers) + def test_retry_scheduled_job + assert_event_reported("active_job.retry_scheduled", payload: { + job_class: RetryJob.name, + executions: 1, + wait_seconds: 1, + exception_class: "StandardError", + exception_message: "Retry me" + }) do + assert_raises(StandardError) do + RetryJob.perform_now + end + end + end + end + + def test_discard_job + event = assert_event_reported("active_job.discarded", payload: { + job_class: DiscardJob.name, + exception_class: "StandardError", + exception_message: "Discard me" + }) do + DiscardJob.perform_now + end + + assert event[:payload][:job_id].present? + end + + def test_bulk_enqueue_jobs + jobs = [TestJob.new, TestJob.new("arg1"), TestJob.new("arg2")] + + assert_event_reported("active_job.bulk_enqueued", payload: { + adapter: ActiveJob.adapter_name(ActiveJob::Base.queue_adapter), + total_jobs: 3, + enqueued_count: 3, + failed_count: 0, + job_classes: { TestJob.name => 3 } + }) do + ActiveJob.perform_all_later(jobs) + end + end + + unless adapter_is?(:inline, :sneakers) + def test_interrupt_job + event = assert_event_reported("active_job.interrupt", payload: { + job_class: ContinuationJob.name, + description: "not started", + reason: "Interrupted" + }) do + ContinuationJob.perform_now(action: :interrupt) + end + + assert event[:payload][:job_id].present? + end + + def test_resume_job + event = assert_event_reported("active_job.resume", payload: { + job_class: ContinuationJob.name, + description: "after 'step'", + }) do + ContinuationJob.perform_now(action: :resume) + end + + assert event[:payload][:job_id].present? + end + + def test_step_skipped_job + event = assert_event_reported("active_job.step_skipped", payload: { + job_class: ContinuationJob.name, + step: "step", + }) do + ContinuationJob.perform_now(action: :skip_step) + end + + assert event[:payload][:job_id].present? + end + + def test_step_resumed_job + event = assert_event_reported("active_job.step_resumed", payload: { + job_class: ContinuationJob.name, + step: :step, + }) do + ContinuationJob.perform_later(action: :resume_step) + end + + assert event[:payload][:job_id].present? + end + + def test_step_started_job + event = assert_event_reported("active_job.step_started", payload: { + job_class: ContinuationJob.name, + step: :step, + }) do + ContinuationJob.perform_now(action: :step) + end + + assert event[:payload][:job_id].present? + end + + def test_step_interrupted_job + event = assert_event_reported("active_job.step_interrupted", payload: { + job_class: ContinuationJob.name, + step: :interrupt_step, + cursor: nil, + }) do + ContinuationJob.perform_now(action: :interrupt_step) + end + + assert event[:payload][:job_id].present? + assert event[:payload][:duration].present? + end + + def test_step_errored_job + event = assert_event_reported("active_job.step_errored", payload: { + job_class: ContinuationJob.name, + step: :error_step, + cursor: nil, + exception_class: "RuntimeError", + exception_message: "Error", + }) do + assert_raises(StandardError) do + ContinuationJob.perform_now(action: :error_step) + end + end + + assert event[:payload][:job_id].present? + assert event[:payload][:duration].present? + end + + def test_step_job + event = assert_event_reported("active_job.step", payload: { + job_class: ContinuationJob.name, + step: :step, + }) do + ContinuationJob.perform_now(action: :step) + end + + assert event[:payload][:job_id].present? + assert event[:payload][:duration].present? + end + end + end +end From 163f7b76037a1ad3ca477b61c243bb8aa6d076fc Mon Sep 17 00:00:00 2001 From: Gannon McGibbon Date: Sun, 14 Sep 2025 16:49:47 -0500 Subject: [PATCH 0669/1075] Structured events for Active Record --- activerecord/CHANGELOG.md | 6 + .../structured_event_subscriber.rb | 82 +++++++++++ .../cases/structured_event_subscriber_test.rb | 127 ++++++++++++++++++ 3 files changed, 215 insertions(+) create mode 100644 activerecord/lib/active_record/structured_event_subscriber.rb create mode 100644 activerecord/test/cases/structured_event_subscriber_test.rb diff --git a/activerecord/CHANGELOG.md b/activerecord/CHANGELOG.md index 6a5ec5a4fe58a..190d62224f600 100644 --- a/activerecord/CHANGELOG.md +++ b/activerecord/CHANGELOG.md @@ -1,3 +1,9 @@ +* Add structured events for Active Record: + - `active_record.strict_loading_violation` + - `active_record.sql` + + *Gannon McGibbon* + * Add support for integer shard keys. ```ruby # Now accepts symbols as shard keys. diff --git a/activerecord/lib/active_record/structured_event_subscriber.rb b/activerecord/lib/active_record/structured_event_subscriber.rb new file mode 100644 index 0000000000000..aac7f6eb30f76 --- /dev/null +++ b/activerecord/lib/active_record/structured_event_subscriber.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +require "active_support/structured_event_subscriber" + +module ActiveRecord + class StructuredEventSubscriber < ActiveSupport::StructuredEventSubscriber # :nodoc: + IGNORE_PAYLOAD_NAMES = ["SCHEMA", "EXPLAIN"] + + def strict_loading_violation(event) + owner = event.payload[:owner] + reflection = event.payload[:reflection] + + emit_debug_event("active_record.strict_loading_violation", + owner: owner.name, + class: reflection.klass.name, + name: reflection.name, + ) + end + + def sql(event) + payload = event.payload + + return if IGNORE_PAYLOAD_NAMES.include?(payload[:name]) + + binds = nil + + if payload[:binds]&.any? + casted_params = type_casted_binds(payload[:type_casted_binds]) + + binds = [] + payload[:binds].each_with_index do |attr, i| + attribute_name = if attr.respond_to?(:name) + attr.name + elsif attr.respond_to?(:[]) && attr[i].respond_to?(:name) + attr[i].name + else + nil + end + + filtered_params = filter(attribute_name, casted_params[i]) + + binds << render_bind(attr, filtered_params) + end + end + + emit_debug_event("active_record.sql", + async: payload[:async], + name: payload[:name], + sql: payload[:sql], + cached: payload[:cached], + lock_wait: payload[:lock_wait], + binds: binds, + ) + end + + private + def type_casted_binds(casted_binds) + casted_binds.respond_to?(:call) ? casted_binds.call : casted_binds + end + + def render_bind(attr, value) + case attr + when ActiveModel::Attribute + if attr.type.binary? && attr.value + value = "<#{attr.value_for_database.to_s.bytesize} bytes of binary data>" + end + when Array + attr = attr.first + else + attr = nil + end + + [attr&.name, value] + end + + def filter(name, value) + ActiveRecord::Base.inspection_filter.filter_param(name, value) + end + end +end + +ActiveRecord::StructuredEventSubscriber.attach_to :active_record diff --git a/activerecord/test/cases/structured_event_subscriber_test.rb b/activerecord/test/cases/structured_event_subscriber_test.rb new file mode 100644 index 0000000000000..a0e95acc11292 --- /dev/null +++ b/activerecord/test/cases/structured_event_subscriber_test.rb @@ -0,0 +1,127 @@ +# frozen_string_literal: true + +require "cases/helper" +require "active_support/testing/event_reporter_assertions" +require "active_record/structured_event_subscriber" +require "models/developer" +require "models/binary" + +module ActiveRecord + class StructuredEventSubscriberTest < ActiveRecord::TestCase + include ActiveSupport::Testing::EventReporterAssertions + + fixtures :developers + + Event = Struct.new(:duration, :payload) + + def run(*) + ActiveSupport.event_reporter.with_debug do + super + end + end + + def test_strict_loding_violation + old_action = ActiveRecord.action_on_strict_loading_violation + ActiveRecord.action_on_strict_loading_violation = :log + developer = Developer.first.tap(&:strict_loading!) + + assert_event_reported("active_record.strict_loading_violation", payload: { + owner: "Developer", + class: "AuditLog", + name: :audit_logs, + }) do + developer.audit_logs.to_a + end + ensure + ActiveRecord.action_on_strict_loading_violation = old_action + end + + def test_schema_statements_are_ignored + subscriber = ActiveRecord::StructuredEventSubscriber.new + + assert_no_event_reported("active_record.sql") do + subscriber.sql(Event.new(0.9, sql: "hi mom!", name: "SCHEMA")) + end + end + + def test_explain_statements_are_ignored + subscriber = ActiveRecord::StructuredEventSubscriber.new + + assert_no_event_reported("active_record.sql") do + subscriber.sql(Event.new(0.9, sql: "hi mom!", name: "EXPLAIN")) + end + end + + def test_basic_query_logging + assert_event_reported("active_record.sql", payload: { + name: "Developer Load", + sql: /SELECT .*?FROM .?developers.?/i, + }) do + Developer.all.load + end + end + + def test_async_query + subscriber = ActiveRecord::StructuredEventSubscriber.new + + assert_event_reported("active_record.sql", payload: { + name: "Model Load", + async: true, + lock_wait: 0.01, + }) do + subscriber.sql(Event.new(0.9, sql: "SELECT * from models", name: "Model Load", async: true, lock_wait: 0.01)) + end + end + + def test_exists_query_logging + assert_event_reported("active_record.sql", payload: { + name: "Developer Exists?", + sql: /SELECT .*?FROM .?developers.?/i, + }) do + Developer.exists? 1 + end + end + + def test_cached_queries + assert_event_reported("active_record.sql", payload: { + name: "Developer Load", + sql: /SELECT .*?FROM .?developers.?/i, + cached: true, + }) do + ActiveRecord::Base.cache do + Developer.all.load + Developer.all.load + end + end + end + + if ActiveRecord::Base.lease_connection.prepared_statements + def test_where_in_binds_logging_include_attribute_names + assert_event_reported("active_record.sql", payload: { + binds: [["id", 1], ["id", 2], ["id", 3], ["id", 4], ["id", 5]] + }) do + Developer.where(id: [1, 2, 3, 4, 5]).load + end + end + + def test_binary_data_is_not_logged + assert_event_reported("active_record.sql", payload: { + binds: [["data", "<16 bytes of binary data>"]] + }) do + Binary.create(data: "some binary data") + end + end + + def test_binary_data_hash + event = assert_event_reported("active_record.sql", payload: { name: "Binary Create" }) do + Binary.create(data: { a: 1 }) + end + + key, value = event.dig(:payload, :binds, 0) + + assert_equal("data", key) + assert_match(/<(6|7) bytes of binary data>/, value) + end + end + end +end From ef36303324a0525d61fb19ad895b10df11f2332d Mon Sep 17 00:00:00 2001 From: Gannon McGibbon Date: Tue, 16 Sep 2025 23:29:31 -0500 Subject: [PATCH 0670/1075] Structured events for Active Storage --- activestorage/CHANGELOG.md | 13 +++ activestorage/lib/active_storage/service.rb | 1 + .../structured_event_subscriber.rb | 67 +++++++++++ .../test/structured_event_subscriber_test.rb | 108 ++++++++++++++++++ 4 files changed, 189 insertions(+) create mode 100644 activestorage/lib/active_storage/structured_event_subscriber.rb create mode 100644 activestorage/test/structured_event_subscriber_test.rb diff --git a/activestorage/CHANGELOG.md b/activestorage/CHANGELOG.md index 78574423646f7..323258c77bee9 100644 --- a/activestorage/CHANGELOG.md +++ b/activestorage/CHANGELOG.md @@ -1,3 +1,16 @@ +* Add structured events for Active Storage: + - `active_storage.service_upload` + - `active_storage.service_download` + - `active_storage.service_streaming_download` + - `active_storage.preview` + - `active_storage.service_delete` + - `active_storage.service_delete_prefixed` + - `active_storage.service_exist` + - `active_storage.service_url` + - `active_storage.service_mirror` + + *Gannon McGibbon* + * Allow analyzers and variant transformer to be fully configurable ```ruby diff --git a/activestorage/lib/active_storage/service.rb b/activestorage/lib/active_storage/service.rb index 85dc2b8da877f..e02de9e51a2d0 100644 --- a/activestorage/lib/active_storage/service.rb +++ b/activestorage/lib/active_storage/service.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "active_storage/log_subscriber" +require "active_storage/structured_event_subscriber" require "active_storage/downloader" require "action_dispatch" require "action_dispatch/http/content_disposition" diff --git a/activestorage/lib/active_storage/structured_event_subscriber.rb b/activestorage/lib/active_storage/structured_event_subscriber.rb new file mode 100644 index 0000000000000..52db68c947833 --- /dev/null +++ b/activestorage/lib/active_storage/structured_event_subscriber.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require "active_support/structured_event_subscriber" + +module ActiveStorage + class StructuredEventSubscriber < ActiveSupport::StructuredEventSubscriber # :nodoc: + def service_upload(event) + emit_event("active_storage.service_upload", + key: event.payload[:key], + checksum: event.payload[:checksum], + ) + end + + def service_download(event) + emit_event("active_storage.service_download", + key: event.payload[:key], + ) + end + + def service_streaming_download(event) + emit_event("active_storage.service_streaming_download", + key: event.payload[:key], + ) + end + + def preview(event) + emit_event("active_storage.preview", + key: event.payload[:key], + ) + end + + def service_delete(event) + emit_event("active_storage.service_delete", + key: event.payload[:key], + ) + end + + def service_delete_prefixed(event) + emit_event("active_storage.service_delete_prefixed", + prefix: event.payload[:prefix], + ) + end + + def service_exist(event) + emit_debug_event("active_storage.service_exist", + key: event.payload[:key], + exist: event.payload[:exist], + ) + end + + def service_url(event) + emit_debug_event("active_storage.service_url", + key: event.payload[:key], + url: event.payload[:url], + ) + end + + def service_mirror(event) + emit_debug_event("active_storage.service_mirror", + key: event.payload[:key], + checksum: event.payload[:checksum], + ) + end + end +end + +ActiveStorage::StructuredEventSubscriber.attach_to :active_storage diff --git a/activestorage/test/structured_event_subscriber_test.rb b/activestorage/test/structured_event_subscriber_test.rb new file mode 100644 index 0000000000000..f44b4eae9030e --- /dev/null +++ b/activestorage/test/structured_event_subscriber_test.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +require "test_helper" +require "active_support/testing/event_reporter_assertions" +require "active_storage/structured_event_subscriber" +require "database/setup" + +module ActiveStorage + class StructuredEventSubscriberTest < ActiveSupport::TestCase + include ActiveSupport::Testing::EventReporterAssertions + + test "service_upload" do + assert_event_reported("active_storage.service_upload", payload: { key: /.*/, checksum: /.*/ }) do + User.create!(name: "Test", avatar: { io: StringIO.new, filename: "avatar.jpg" }) + end + end + + test "service_download" do + blob = create_blob(filename: "avatar.jpg") + user = User.create!(name: "Test", avatar: blob) + + assert_event_reported("active_storage.service_download", payload: { key: user.avatar.key }) do + user.avatar.download + end + end + + test "service_streaming_download" do + blob = create_blob(filename: "avatar.jpg") + user = User.create!(name: "Test", avatar: blob) + + assert_event_reported("active_storage.service_streaming_download", payload: { key: user.avatar.key }) do + user.avatar.download { } + end + end + + test "preview" do + blob = create_file_blob(filename: "cropped.pdf", content_type: "application/pdf") + user = User.create!(name: "Test", avatar: blob) + + assert_event_reported("active_storage.preview", payload: { key: user.avatar.key }) do + user.avatar.preview(resize_to_limit: [640, 280]).processed + end + end + + test "service_delete" do + blob = create_blob(filename: "avatar.jpg") + user = User.create!(name: "Test", avatar: blob) + + assert_event_reported("active_storage.service_delete", payload: { key: user.avatar.key }) do + user.avatar.purge + end + end + + test "service_delete_prefixed" do + blob = create_file_blob(fixture: "colors.bmp") + user = User.create!(name: "Test", avatar: blob) + + assert_event_reported("active_storage.service_delete_prefixed", payload: { prefix: /variants\/.*/ }) do + user.avatar.purge + end + end + + test "service_exist" do + blob = create_blob(filename: "avatar.jpg") + user = User.create!(name: "Test", avatar: blob) + + ActiveSupport.event_reporter.with_debug do + assert_event_reported("active_storage.service_exist", payload: { key: /.*/, exist: true }) do + user.avatar.service.exist? user.avatar.key + end + end + end + + test "service_url" do + blob = create_blob(filename: "avatar.jpg") + user = User.create!(name: "Test", avatar: blob) + + ActiveSupport.event_reporter.with_debug do + assert_event_reported("active_storage.service_url", payload: { key: /.*/, url: /.*/ }) do + user.avatar.url + end + end + end + + test "service_mirror" do + blob = create_blob(filename: "avatar.jpg") + + mirror_config = (1..3).to_h do |i| + [ "mirror_#{i}", + service: "Disk", + root: Dir.mktmpdir("active_storage_tests_mirror_#{i}") ] + end + + config = mirror_config.merge \ + mirror: { service: "Mirror", primary: "primary", mirrors: mirror_config.keys }, + primary: { service: "Disk", root: Dir.mktmpdir("active_storage_tests_primary") } + + service = ActiveStorage::Service.configure :mirror, config + service.upload blob.key, StringIO.new(blob.download), checksum: blob.checksum + + ActiveSupport.event_reporter.with_debug do + assert_event_reported("active_storage.service_mirror", payload: { key: /.*/, url: /.*/ }) do + service.mirror blob.key, checksum: blob.checksum + end + end + end + end +end From a486b99a4f48d61171cb345a8e03d7436e825984 Mon Sep 17 00:00:00 2001 From: Gannon McGibbon Date: Tue, 16 Sep 2025 23:45:53 -0500 Subject: [PATCH 0671/1075] Structured events for Action Mailer --- actionmailer/CHANGELOG.md | 8 +++ actionmailer/lib/action_mailer/base.rb | 1 + .../structured_event_subscriber.rb | 41 +++++++++++++ .../test/structured_event_subscriber_test.rb | 57 +++++++++++++++++++ 4 files changed, 107 insertions(+) create mode 100644 actionmailer/lib/action_mailer/structured_event_subscriber.rb create mode 100644 actionmailer/test/structured_event_subscriber_test.rb diff --git a/actionmailer/CHANGELOG.md b/actionmailer/CHANGELOG.md index 5d207d8e0baf1..c8bf367514896 100644 --- a/actionmailer/CHANGELOG.md +++ b/actionmailer/CHANGELOG.md @@ -1,3 +1,11 @@ +* Add structured events for Action Mailer: + - `action_mailer.delivery_error` + - `action_mailer.delivered` + - `action_mailer.delivery_skipped` + - `action_mailer.processed` + + *Gannon McGibbon* + ## Rails 8.1.0.beta1 (September 04, 2025) ## * Add `deliver_all_later` to enqueue multiple emails at once. diff --git a/actionmailer/lib/action_mailer/base.rb b/actionmailer/lib/action_mailer/base.rb index bc3e8f306ba52..4ad5145995a96 100644 --- a/actionmailer/lib/action_mailer/base.rb +++ b/actionmailer/lib/action_mailer/base.rb @@ -7,6 +7,7 @@ require "active_support/core_ext/module/anonymous" require "action_mailer/log_subscriber" +require "action_mailer/structured_event_subscriber" require "action_mailer/rescuable" module ActionMailer diff --git a/actionmailer/lib/action_mailer/structured_event_subscriber.rb b/actionmailer/lib/action_mailer/structured_event_subscriber.rb new file mode 100644 index 0000000000000..29d74ae8481ba --- /dev/null +++ b/actionmailer/lib/action_mailer/structured_event_subscriber.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require "active_support/structured_event_subscriber" + +module ActionMailer + class StructuredEventSubscriber < ActiveSupport::StructuredEventSubscriber # :nodoc: + # An email was delivered. + def deliver(event) + if (exception = event.payload[:exception_object]) + emit_debug_event("action_mailer.delivery_error", + message_id: event.payload[:message_id], + exception_class: exception.class.name, + exception_message: exception.message, + mail: event.payload[:mail], + ) + elsif event.payload[:perform_deliveries] + emit_debug_event("action_mailer.delivered", + message_id: event.payload[:message_id], + duration: event.duration.round(1), + mail: event.payload[:mail], + ) + else + emit_debug_event("action_mailer.delivery_skipped", + message_id: event.payload[:message_id], + mail: event.payload[:mail], + ) + end + end + + # An email was generated. + def process(event) + emit_debug_event("action_mailer.processed", + mailer: event.payload[:mailer], + action: event.payload[:action], + duration: event.duration.round(1), + ) + end + end +end + +ActionMailer::StructuredEventSubscriber.attach_to :action_mailer diff --git a/actionmailer/test/structured_event_subscriber_test.rb b/actionmailer/test/structured_event_subscriber_test.rb new file mode 100644 index 0000000000000..b225c912f7455 --- /dev/null +++ b/actionmailer/test/structured_event_subscriber_test.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require "abstract_unit" +require "active_support/testing/event_reporter_assertions" +require "mailers/base_mailer" +require "action_mailer/structured_event_subscriber" + +module ActionMailer + class StructuredEventSubscriberTest < ActionMailer::TestCase + include ActiveSupport::Testing::EventReporterAssertions + + class BogusDelivery + def initialize(*) + end + + def deliver!(mail) + raise "failed" + end + end + + def run(*) + ActiveSupport.event_reporter.with_debug do + super + end + end + + def test_deliver_is_notified + event = assert_event_reported("action_mailer.delivered", payload: { message_id: "123@abc", mail: /.*/ }) do + BaseMailer.welcome(message_id: "123@abc").deliver_now + end + + assert event[:payload][:duration] > 0 + ensure + BaseMailer.deliveries.clear + end + + def test_deliver_message_when_perform_deliveries_is_false + assert_event_reported("action_mailer.delivery_skipped", payload: { message_id: "123@abc", mail: /.*/ }) do + BaseMailer.welcome_without_deliveries(message_id: "123@abc").deliver_now + end + ensure + BaseMailer.deliveries.clear + end + + def test_deliver_message_when_exception_happened + previous_delivery_method = BaseMailer.delivery_method + BaseMailer.delivery_method = BogusDelivery + payload = { message_id: "123@abc", mail: /.*/, exception_class: "RuntimeError", exception_message: "failed" } + + assert_event_reported("action_mailer.delivery_error", payload:) do + assert_raises(RuntimeError) { BaseMailer.welcome(message_id: "123@abc").deliver_now } + end + ensure + BaseMailer.delivery_method = previous_delivery_method + end + end +end From 333d51bebfee74b2bfd7c8f02291d1f598b1ed4a Mon Sep 17 00:00:00 2001 From: Gannon McGibbon Date: Wed, 17 Sep 2025 01:47:59 -0500 Subject: [PATCH 0672/1075] Structured events for Action View --- actionview/CHANGELOG.md | 9 + actionview/lib/action_view/base.rb | 1 + .../structured_event_subscriber.rb | 92 +++++ .../structured_event_subscriber_test.rb | 353 ++++++++++++++++++ 4 files changed, 455 insertions(+) create mode 100644 actionview/lib/action_view/structured_event_subscriber.rb create mode 100644 actionview/test/template/structured_event_subscriber_test.rb diff --git a/actionview/CHANGELOG.md b/actionview/CHANGELOG.md index ece25269dbc30..75510fba4af96 100644 --- a/actionview/CHANGELOG.md +++ b/actionview/CHANGELOG.md @@ -1,3 +1,12 @@ +* Add structured events for Action View: + - `action_view.render_template` + - `action_view.render_partial` + - `action_view.render_layout` + - `action_view.render_collection` + - `action_view.render_start` + + *Gannon McGibbon* + * Fix label with `for` option not getting prefixed by form `namespace` value *Abeid Ahmed*, *Hartley McGuire* diff --git a/actionview/lib/action_view/base.rb b/actionview/lib/action_view/base.rb index f85e0d5941f0f..1e865a872dc02 100644 --- a/actionview/lib/action_view/base.rb +++ b/actionview/lib/action_view/base.rb @@ -4,6 +4,7 @@ require "active_support/core_ext/module/attribute_accessors" require "active_support/ordered_options" require "action_view/log_subscriber" +require "action_view/structured_event_subscriber" require "action_view/helpers" require "action_view/context" require "action_view/template" diff --git a/actionview/lib/action_view/structured_event_subscriber.rb b/actionview/lib/action_view/structured_event_subscriber.rb new file mode 100644 index 0000000000000..ac27cd5be326e --- /dev/null +++ b/actionview/lib/action_view/structured_event_subscriber.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +require "active_support/structured_event_subscriber" + +module ActionView + class StructuredEventSubscriber < ActiveSupport::StructuredEventSubscriber # :nodoc: + VIEWS_PATTERN = /^app\/views\// + + def initialize + @root = nil + super + end + + def render_template(event) + emit_debug_event("action_view.render_template", + identifier: from_rails_root(event.payload[:identifier]), + layout: from_rails_root(event.payload[:layout]), + duration: event.duration.round(1), + gc: event.gc_time.round(1), + ) + end + + def render_partial(event) + emit_debug_event("action_view.render_partial", + identifier: from_rails_root(event.payload[:identifier]), + layout: from_rails_root(event.payload[:layout]), + duration: event.duration.round(1), + gc: event.gc_time.round(1), + cache_hit: event.payload[:cache_hit], + ) + end + + def render_layout(event) + emit_event("action_view.render_layout", + identifier: from_rails_root(event.payload[:identifier]), + duration: event.duration.round(1), + gc: event.gc_time.round(1), + ) + end + + def render_collection(event) + emit_debug_event("action_view.render_collection", + identifier: from_rails_root(event.payload[:identifier] || "templates"), + layout: from_rails_root(event.payload[:layout]), + duration: event.duration.round(1), + gc: event.gc_time.round(1), + cache_hits: event.payload[:cache_hits], + count: event.payload[:count], + ) + end + + module Utils # :nodoc: + private + def from_rails_root(string) + return unless string + + string = string.sub("#{rails_root}/", "") + string.sub!(VIEWS_PATTERN, "") + string + end + + def rails_root # :doc: + @root ||= Rails.try(:root) + end + end + + include Utils + + class Start # :nodoc: + include Utils + + def start(name, id, payload) + ActiveSupport.event_reporter.debug("action_view.render_start", + identifier: from_rails_root(payload[:identifier]), + layout: from_rails_root(payload[:layout]), + ) + end + + def finish(name, id, payload) + end + end + + def self.attach_to(*) + ActiveSupport::Notifications.subscribe("render_template.action_view", Start.new) + ActiveSupport::Notifications.subscribe("render_layout.action_view", Start.new) + + super + end + end +end + +ActionView::StructuredEventSubscriber.attach_to :action_view diff --git a/actionview/test/template/structured_event_subscriber_test.rb b/actionview/test/template/structured_event_subscriber_test.rb new file mode 100644 index 0000000000000..1e30c64d293cd --- /dev/null +++ b/actionview/test/template/structured_event_subscriber_test.rb @@ -0,0 +1,353 @@ +# frozen_string_literal: true + +require "abstract_unit" +require "active_support/testing/event_reporter_assertions" +require "action_view/structured_event_subscriber" +require "controller/fake_models" + +module ActionView + class StructuredEventSubscriberTest < ActiveSupport::TestCase + include ActiveSupport::Testing::EventReporterAssertions + + def setup + super + + ActionView::LookupContext::DetailsKey.clear + + view_paths = ActionController::Base.view_paths + + lookup_context = ActionView::LookupContext.new(view_paths, {}, ["test"]) + @view = ActionView::Base.with_empty_template_cache.with_context(lookup_context) + + unless Rails.respond_to?(:root) + @defined_root = true + Rails.define_singleton_method(:root) { :defined_root } # Minitest `stub` expects the method to be defined. + end + end + + def teardown + super + ActionController::Base.view_paths.map(&:clear_cache) + + # We need to undef `root`, RenderTestCases don't want this to be defined + Rails.instance_eval { undef :root } if defined?(@defined_root) + end + + def test_render_template + Rails.stub(:root, File.expand_path(FIXTURE_LOAD_PATH)) do + ActiveSupport.event_reporter.with_debug do + assert_event_reported("action_view.render_start", payload: { identifier: "test/hello_world.erb", layout: nil }) do + event = assert_event_reported("action_view.render_template", payload: { identifier: "test/hello_world.erb", layout: nil }) do + @view.render(template: "test/hello_world") + end + + assert(event[:payload][:gc] >= 0) + assert(event[:payload][:duration] >= 0) + end + end + end + end + + def test_render_template_with_layout + Rails.stub(:root, File.expand_path(FIXTURE_LOAD_PATH)) do + ActiveSupport.event_reporter.with_debug do + payload = { identifier: "test/hello_world.erb", layout: "layouts/yield" } + assert_event_reported("action_view.render_start", payload:) do + event = assert_event_reported("action_view.render_template", payload:) do + @view.render(template: "test/hello_world", layout: "layouts/yield") + end + + assert(event[:payload][:gc] >= 0) + assert(event[:payload][:duration] >= 0) + end + end + end + end + + def test_render_file_template + Rails.stub(:root, File.expand_path(FIXTURE_LOAD_PATH)) do + ActiveSupport.event_reporter.with_debug do + assert_event_reported("action_view.render_start", payload: { identifier: "test/hello_world.erb", layout: nil }) do + event = assert_event_reported("action_view.render_template", payload: { identifier: "test/hello_world.erb", layout: nil }) do + @view.render(file: "#{FIXTURE_LOAD_PATH}/test/hello_world.erb") + end + + assert(event[:payload][:gc] >= 0) + assert(event[:payload][:duration] >= 0) + end + end + end + end + + def test_render_text_template + Rails.stub(:root, File.expand_path(FIXTURE_LOAD_PATH)) do + ActiveSupport.event_reporter.with_debug do + assert_event_reported("action_view.render_start", payload: { identifier: "text template", layout: nil }) do + event = assert_event_reported("action_view.render_template", payload: { identifier: "text template", layout: nil }) do + @view.render(plain: "TEXT") + end + + assert(event[:payload][:gc] >= 0) + assert(event[:payload][:duration] >= 0) + end + end + end + end + + def test_render_inline_template + Rails.stub(:root, File.expand_path(FIXTURE_LOAD_PATH)) do + ActiveSupport.event_reporter.with_debug do + assert_event_reported("action_view.render_start", payload: { identifier: "inline template", layout: nil }) do + event = assert_event_reported("action_view.render_template", payload: { identifier: "inline template", layout: nil }) do + @view.render(inline: "<%= 'TEXT' %>") + end + + assert(event[:payload][:gc] >= 0) + assert(event[:payload][:duration] >= 0) + end + end + end + end + + def test_render_partial_with_implicit_path + Rails.stub(:root, File.expand_path(FIXTURE_LOAD_PATH)) do + ActiveSupport.event_reporter.with_debug do + payload = { identifier: "customers/_customer.html.erb", layout: nil, cache_hit: nil } + event = assert_event_reported("action_view.render_partial", payload:) do + @view.render(Customer.new("david"), greeting: "hi") + end + + assert(event[:payload][:gc] >= 0) + assert(event[:payload][:duration] >= 0) + end + end + end + + def test_render_partial_with_cache_is_missed + Rails.stub(:root, File.expand_path(FIXTURE_LOAD_PATH)) do + set_view_cache_dependencies + set_cache_controller + + ActiveSupport.event_reporter.with_debug do + payload = { identifier: "test/_cached_customer.erb", layout: nil, cache_hit: :miss } + event = assert_event_reported("action_view.render_partial", payload:) do + @view.render(partial: "test/cached_customer", locals: { cached_customer: Customer.new("david") }) + end + + assert(event[:payload][:gc] >= 0) + assert(event[:payload][:duration] >= 0) + end + end + end + + def test_render_partial_with_cache_is_hit + Rails.stub(:root, File.expand_path(FIXTURE_LOAD_PATH)) do + set_view_cache_dependencies + set_cache_controller + + @view.render(partial: "test/cached_customer", locals: { cached_customer: Customer.new("david") }) + + ActiveSupport.event_reporter.with_debug do + payload = { identifier: "test/_cached_customer.erb", layout: nil, cache_hit: :hit } + event = assert_event_reported("action_view.render_partial", payload:) do + # Second render should hit cache. + @view.render(partial: "test/cached_customer", locals: { cached_customer: Customer.new("david") }) + end + + assert(event[:payload][:gc] >= 0) + assert(event[:payload][:duration] >= 0) + end + end + end + + def test_render_partial_as_layout + Rails.stub(:root, File.expand_path(FIXTURE_LOAD_PATH)) do + set_view_cache_dependencies + set_cache_controller + + ActiveSupport.event_reporter.with_debug do + event = assert_event_reported("action_view.render_partial", payload: { identifier: "layouts/_yield_only.erb", layout: nil }) do + @view.render(layout: "layouts/yield_only") { "hello" } + end + + assert(event[:payload][:gc] >= 0) + assert(event[:payload][:duration] >= 0) + end + end + end + + def test_render_partial_with_layout + Rails.stub(:root, File.expand_path(FIXTURE_LOAD_PATH)) do + set_view_cache_dependencies + set_cache_controller + + ActiveSupport.event_reporter.with_debug do + payload = { identifier: "test/_partial.html.erb", layout: "layouts/_yield_only" } + event = assert_event_reported("action_view.render_partial", payload:) do + @view.render(partial: "partial", layout: "layouts/yield_only") + end + + assert(event[:payload][:gc] >= 0) + assert(event[:payload][:duration] >= 0) + end + end + end + + def test_render_uncached_outer_partial_with_inner_cached_partial_wont_mix_cache_hits_or_misses + Rails.stub(:root, File.expand_path(FIXTURE_LOAD_PATH)) do + set_view_cache_dependencies + set_cache_controller + + ActiveSupport.event_reporter.with_debug do + event = assert_event_reported("action_view.render_partial", payload: { identifier: "test/_cached_customer.erb", layout: nil }) do + @view.render(partial: "test/nested_cached_customer", locals: { cached_customer: Customer.new("Stan") }) + end + + assert(event[:payload][:gc] >= 0) + assert(event[:payload][:duration] >= 0) + + payload = { identifier: "test/_cached_customer.erb", layout: nil, cache_hit: :hit } + event = assert_event_reported("action_view.render_partial", payload:) do + # Second render hits the cache for the _cached_customer partial. Outer template's log shouldn't be affected. + @view.render(partial: "test/nested_cached_customer", locals: { cached_customer: Customer.new("Stan") }) + end + + assert(event[:payload][:gc] >= 0) + assert(event[:payload][:duration] >= 0) + end + end + end + + def test_render_cached_outer_partial_with_cached_inner_partial + Rails.stub(:root, File.expand_path(FIXTURE_LOAD_PATH)) do + set_view_cache_dependencies + set_cache_controller + + ActiveSupport.event_reporter.with_debug do + assert_event_reported("action_view.render_partial", payload: { identifier: "test/_cached_customer.erb", layout: nil }) do + assert_event_reported("action_view.render_partial", payload: { identifier: "test/_cached_nested_cached_customer.erb", layout: nil, cache_hit: :miss }) do + @view.render(partial: "test/cached_nested_cached_customer", locals: { cached_customer: Customer.new("Stan") }) + end + end + + # One render: inner partial skipped, because the outer has been cached. + assert_no_event_reported("action_view.render_partial", payload: { identifier: "test/_cached_customer.erb", layout: nil }) do + assert_event_reported("action_view.render_partial", payload: { identifier: "test/_cached_nested_cached_customer.erb", layout: nil, cache_hit: :hit }) do + @view.render(partial: "test/cached_nested_cached_customer", locals: { cached_customer: Customer.new("Stan") }) + end + end + end + end + end + + def test_render_partial_with_cache_hit_and_missed + Rails.stub(:root, File.expand_path(FIXTURE_LOAD_PATH)) do + set_view_cache_dependencies + set_cache_controller + + ActiveSupport.event_reporter.with_debug do + assert_event_reported("action_view.render_partial", payload: { identifier: "test/_cached_customer.erb", layout: nil, cache_hit: :miss }) do + @view.render(partial: "test/cached_customer", locals: { cached_customer: Customer.new("david") }) + end + assert_event_reported("action_view.render_partial", payload: { identifier: "test/_cached_customer.erb", layout: nil, cache_hit: :hit }) do + @view.render(partial: "test/cached_customer", locals: { cached_customer: Customer.new("david") }) + end + + assert_event_reported("action_view.render_partial", payload: { identifier: "test/_cached_customer.erb", layout: nil, cache_hit: :miss }) do + @view.render(partial: "test/cached_customer", locals: { cached_customer: Customer.new("Stan") }) + end + end + end + end + + def test_render_collection_template + Rails.stub(:root, File.expand_path(FIXTURE_LOAD_PATH)) do + set_cache_controller + + ActiveSupport.event_reporter.with_debug do + event = assert_event_reported("action_view.render_collection", payload: { identifier: "test/_customer.erb", layout: nil, cache_hits: nil, count: 2 }) do + @view.render(partial: "test/customer", collection: [ Customer.new("david"), Customer.new("mary") ]) + end + + assert(event[:payload][:gc] >= 0) + assert(event[:payload][:duration] >= 0) + end + end + end + + def test_render_collection_template_with_layout + Rails.stub(:root, File.expand_path(FIXTURE_LOAD_PATH)) do + set_cache_controller + + ActiveSupport.event_reporter.with_debug do + event = assert_event_reported("action_view.render_collection", payload: { identifier: "test/_customer.erb", layout: "layouts/_yield_only", cache_hits: nil, count: 2 }) do + @view.render(partial: "test/customer", layout: "layouts/yield_only", collection: [ Customer.new("david"), Customer.new("mary") ]) + end + + assert(event[:payload][:gc] >= 0) + assert(event[:payload][:duration] >= 0) + end + end + end + + def test_render_collection_with_implicit_path + Rails.stub(:root, File.expand_path(FIXTURE_LOAD_PATH)) do + set_cache_controller + + ActiveSupport.event_reporter.with_debug do + event = assert_event_reported("action_view.render_collection", payload: { identifier: "customers/_customer.html.erb", layout: nil, cache_hits: nil, count: 2 }) do + @view.render([ Customer.new("david"), Customer.new("mary") ], greeting: "hi") + end + + assert(event[:payload][:gc] >= 0) + assert(event[:payload][:duration] >= 0) + end + end + end + + def test_render_collection_template_without_path + Rails.stub(:root, File.expand_path(FIXTURE_LOAD_PATH)) do + set_cache_controller + + ActiveSupport.event_reporter.with_debug do + event = assert_event_reported("action_view.render_collection", payload: { identifier: "templates", layout: nil, cache_hits: nil, count: 2 }) do + @view.render([ GoodCustomer.new("david"), Customer.new("mary") ], greeting: "hi") + end + + assert(event[:payload][:gc] >= 0) + assert(event[:payload][:duration] >= 0) + end + end + end + + def test_render_collection_with_cached_set + Rails.stub(:root, File.expand_path(FIXTURE_LOAD_PATH)) do + set_view_cache_dependencies + set_cache_controller + + ActiveSupport.event_reporter.with_debug do + event = assert_event_reported("action_view.render_collection", payload: { identifier: "customers/_customer.html.erb", layout: nil, cache_hits: 0, count: 2 }) do + @view.render(partial: "customers/customer", collection: [ Customer.new("david"), Customer.new("mary") ], cached: true, + locals: { greeting: "hi" }) + end + + assert(event[:payload][:gc] >= 0) + assert(event[:payload][:duration] >= 0) + end + end + end + + private + def set_cache_controller + controller = ActionController::Base.new + controller.perform_caching = true + controller.cache_store = ActiveSupport::Cache::MemoryStore.new + @view.controller = controller + end + + def set_view_cache_dependencies + def @view.view_cache_dependencies; []; end + def @view.combined_fragment_cache_key(*); "ahoy_2 `controller` dependency"; end + end + end +end From 6f26cc812145863492f71da532b320e3a3024669 Mon Sep 17 00:00:00 2001 From: Gannon McGibbon Date: Fri, 19 Sep 2025 23:16:17 -0500 Subject: [PATCH 0673/1075] Add method silencing support for debug events --- .../structured_event_subscriber.rb | 2 ++ .../structured_event_subscriber.rb | 1 + .../structured_event_subscriber.rb | 4 +++ .../structured_event_subscriber.rb | 2 ++ .../structured_event_subscriber.rb | 3 ++ .../structured_event_subscriber.rb | 35 +++++++++++++++++++ .../test/structured_event_subscriber_test.rb | 26 +++++++++++++- 7 files changed, 72 insertions(+), 1 deletion(-) diff --git a/actionmailer/lib/action_mailer/structured_event_subscriber.rb b/actionmailer/lib/action_mailer/structured_event_subscriber.rb index 29d74ae8481ba..d2a730e221f1d 100644 --- a/actionmailer/lib/action_mailer/structured_event_subscriber.rb +++ b/actionmailer/lib/action_mailer/structured_event_subscriber.rb @@ -26,6 +26,7 @@ def deliver(event) ) end end + debug_only :deliver # An email was generated. def process(event) @@ -35,6 +36,7 @@ def process(event) duration: event.duration.round(1), ) end + debug_only :process end end diff --git a/actionpack/lib/action_controller/structured_event_subscriber.rb b/actionpack/lib/action_controller/structured_event_subscriber.rb index 04ebfa58e5b92..5061495da9724 100644 --- a/actionpack/lib/action_controller/structured_event_subscriber.rb +++ b/actionpack/lib/action_controller/structured_event_subscriber.rb @@ -80,6 +80,7 @@ def unpermitted_parameters(event) params: ) end + debug_only :unpermitted_parameters %w(write_fragment read_fragment exist_fragment? expire_fragment).each do |method| class_eval <<-METHOD, __FILE__, __LINE__ + 1 diff --git a/actionview/lib/action_view/structured_event_subscriber.rb b/actionview/lib/action_view/structured_event_subscriber.rb index ac27cd5be326e..bd7d8025bf92e 100644 --- a/actionview/lib/action_view/structured_event_subscriber.rb +++ b/actionview/lib/action_view/structured_event_subscriber.rb @@ -19,6 +19,7 @@ def render_template(event) gc: event.gc_time.round(1), ) end + debug_only :render_template def render_partial(event) emit_debug_event("action_view.render_partial", @@ -29,6 +30,7 @@ def render_partial(event) cache_hit: event.payload[:cache_hit], ) end + debug_only :render_partial def render_layout(event) emit_event("action_view.render_layout", @@ -37,6 +39,7 @@ def render_layout(event) gc: event.gc_time.round(1), ) end + debug_only :render_layout def render_collection(event) emit_debug_event("action_view.render_collection", @@ -48,6 +51,7 @@ def render_collection(event) count: event.payload[:count], ) end + debug_only :render_collection module Utils # :nodoc: private diff --git a/activerecord/lib/active_record/structured_event_subscriber.rb b/activerecord/lib/active_record/structured_event_subscriber.rb index aac7f6eb30f76..da50e6d46b15a 100644 --- a/activerecord/lib/active_record/structured_event_subscriber.rb +++ b/activerecord/lib/active_record/structured_event_subscriber.rb @@ -16,6 +16,7 @@ def strict_loading_violation(event) name: reflection.name, ) end + debug_only :strict_loading_violation def sql(event) payload = event.payload @@ -52,6 +53,7 @@ def sql(event) binds: binds, ) end + debug_only :sql private def type_casted_binds(casted_binds) diff --git a/activestorage/lib/active_storage/structured_event_subscriber.rb b/activestorage/lib/active_storage/structured_event_subscriber.rb index 52db68c947833..59f55dc8a65bc 100644 --- a/activestorage/lib/active_storage/structured_event_subscriber.rb +++ b/activestorage/lib/active_storage/structured_event_subscriber.rb @@ -47,6 +47,7 @@ def service_exist(event) exist: event.payload[:exist], ) end + debug_only :service_exist def service_url(event) emit_debug_event("active_storage.service_url", @@ -54,6 +55,7 @@ def service_url(event) url: event.payload[:url], ) end + debug_only :service_url def service_mirror(event) emit_debug_event("active_storage.service_mirror", @@ -61,6 +63,7 @@ def service_mirror(event) checksum: event.payload[:checksum], ) end + debug_only :service_mirror end end diff --git a/activesupport/lib/active_support/structured_event_subscriber.rb b/activesupport/lib/active_support/structured_event_subscriber.rb index 5fcb4266cf9b6..d2348499cdc1f 100644 --- a/activesupport/lib/active_support/structured_event_subscriber.rb +++ b/activesupport/lib/active_support/structured_event_subscriber.rb @@ -29,6 +29,41 @@ module ActiveSupport # it will properly dispatch the event (+ActiveSupport::Notifications::Event+) to the +start_processing+ method. # The subscriber can then emit a structured event via the +emit_event+ method. class StructuredEventSubscriber < Subscriber + class_attribute :debug_methods, instance_accessor: false, default: [] # :nodoc: + + DEBUG_CHECK = proc { !ActiveSupport.event_reporter.debug_mode? } + + class << self + def attach_to(...) # :nodoc: + result = super + set_silenced_events + result + end + + private + def set_silenced_events + if subscriber + subscriber.silenced_events = debug_methods.to_h { |method| ["#{method}.#{namespace}", DEBUG_CHECK] } + end + end + + def debug_only(method) + self.debug_methods << method + set_silenced_events + end + end + + def initialize + super + @silenced_events = {} + end + + def silenced?(event) + @silenced_events[event]&.call + end + + attr_writer :silenced_events # :nodoc: + # Emit a structured event via Rails.event.notify. # # ==== Arguments diff --git a/activesupport/test/structured_event_subscriber_test.rb b/activesupport/test/structured_event_subscriber_test.rb index 10c828ecd3398..9cb7d7f96ea61 100644 --- a/activesupport/test/structured_event_subscriber_test.rb +++ b/activesupport/test/structured_event_subscriber_test.rb @@ -6,8 +6,18 @@ class StructuredEventSubscriberTest < ActiveSupport::TestCase include ActiveSupport::Testing::EventReporterAssertions + class TestSubscriber < ActiveSupport::StructuredEventSubscriber + class DebugOnlyError < StandardError + end + + def debug_only_event(event) + raise DebugOnlyError + end + debug_only :debug_only_event + end + def setup - @subscriber = ActiveSupport::StructuredEventSubscriber.new + @subscriber = TestSubscriber.new end def test_emit_event_calls_event_reporter_notify @@ -70,4 +80,18 @@ def test_publish_event_handles_errors assert_match(/undefined method (`|')error_event'/, error_report.error.message) assert_equal "error_event.test", error_report.source end + + def test_debug_only_methods + ActiveSupport::StructuredEventSubscriber.attach_to :test, @subscriber + + assert_no_error_reported do + ActiveSupport::Notifications.instrument("debug_only_event.test") + end + + assert_error_reported(TestSubscriber::DebugOnlyError) do + ActiveSupport.event_reporter.with_debug do + ActiveSupport::Notifications.instrument("debug_only_event.test") + end + end + end end From e729f8683137f86ace0b72b403003268a2bf63ec Mon Sep 17 00:00:00 2001 From: Gannon McGibbon Date: Sat, 20 Sep 2025 03:11:38 -0500 Subject: [PATCH 0674/1075] Silence strucutred event subscribers when no reporter subscribers --- .../lib/active_support/event_reporter.rb | 2 ++ .../structured_event_subscriber.rb | 2 +- .../test/structured_event_subscriber_test.rb | 27 +++++++++++++++++++ 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/activesupport/lib/active_support/event_reporter.rb b/activesupport/lib/active_support/event_reporter.rb index adaca45c5e1c4..b43bcd5577bba 100644 --- a/activesupport/lib/active_support/event_reporter.rb +++ b/activesupport/lib/active_support/event_reporter.rb @@ -275,6 +275,8 @@ class EventReporter attr_writer :debug_mode # :nodoc: + attr_reader :subscribers # :nodoc + class << self attr_accessor :context_store # :nodoc: end diff --git a/activesupport/lib/active_support/structured_event_subscriber.rb b/activesupport/lib/active_support/structured_event_subscriber.rb index d2348499cdc1f..bea5c941b7f89 100644 --- a/activesupport/lib/active_support/structured_event_subscriber.rb +++ b/activesupport/lib/active_support/structured_event_subscriber.rb @@ -59,7 +59,7 @@ def initialize end def silenced?(event) - @silenced_events[event]&.call + ActiveSupport.event_reporter.subscribers.none? || @silenced_events[event]&.call end attr_writer :silenced_events # :nodoc: diff --git a/activesupport/test/structured_event_subscriber_test.rb b/activesupport/test/structured_event_subscriber_test.rb index 9cb7d7f96ea61..ca328ae373a40 100644 --- a/activesupport/test/structured_event_subscriber_test.rb +++ b/activesupport/test/structured_event_subscriber_test.rb @@ -6,10 +6,19 @@ class StructuredEventSubscriberTest < ActiveSupport::TestCase include ActiveSupport::Testing::EventReporterAssertions + class TestEventReporterSubscriber + def emit(payload) + end + end + class TestSubscriber < ActiveSupport::StructuredEventSubscriber class DebugOnlyError < StandardError end + def event(event) + emit_event("test.event", **event.payload) + end + def debug_only_event(event) raise DebugOnlyError end @@ -84,6 +93,9 @@ def test_publish_event_handles_errors def test_debug_only_methods ActiveSupport::StructuredEventSubscriber.attach_to :test, @subscriber + event_reporter_subscriber = TestEventReporterSubscriber.new + ActiveSupport.event_reporter.subscribe(event_reporter_subscriber) + assert_no_error_reported do ActiveSupport::Notifications.instrument("debug_only_event.test") end @@ -93,5 +105,20 @@ def test_debug_only_methods ActiveSupport::Notifications.instrument("debug_only_event.test") end end + ensure + ActiveSupport.event_reporter.unsubscribe(event_reporter_subscriber) + end + + def test_no_event_reporter_subscribers + ActiveSupport::StructuredEventSubscriber.attach_to :test, @subscriber + + old_subscribers = ActiveSupport.event_reporter.subscribers.dup + ActiveSupport.event_reporter.subscribers.clear + + assert_not_called @subscriber, :emit_event do + ActiveSupport::Notifications.instrument("event.test") + end + ensure + ActiveSupport.event_reporter.subscribers.push(*old_subscribers) end end From 0a0addc0e9f1d0bb0406066f9e2a23c3b45ec3cf Mon Sep 17 00:00:00 2001 From: Jill Klang Date: Fri, 19 Sep 2025 14:28:24 -0400 Subject: [PATCH 0675/1075] Optionally skip bundler-audit. Running `bin/rails app:update` with Rails 8.1 adds bin/bundler-audit and config/bundler-audit.yml even if it's not in the Gemfile already. This checks whether bundler_audit is in the bundle and otherwise skips for the app generator. Adds config option to skip bundler-audit in new applications. Extends --minimal option to include bundler-audit Updates changelog Updates test assertions re: bundler-audit; the #generate_test_dummy method skips bundle and bundler-audit, so it should not be expected to be present --- railties/CHANGELOG.md | 8 +++++++ .../lib/rails/commands/app/update_command.rb | 1 + railties/lib/rails/generators/app_base.rb | 7 ++++++ .../generators/rails/app/app_generator.rb | 11 +++++---- .../generators/rails/app/templates/Gemfile.tt | 2 ++ .../rails/app/templates/config/ci.rb.tt | 2 ++ .../rails/app/templates/github/ci.yml.tt | 6 ++++- .../rails/plugin/plugin_generator.rb | 1 + railties/test/application/bin_ci_test.rb | 1 + .../test/generators/app_generator_test.rb | 23 +++++++++++++++++++ .../test/generators/plugin_generator_test.rb | 2 -- 11 files changed, 56 insertions(+), 8 deletions(-) diff --git a/railties/CHANGELOG.md b/railties/CHANGELOG.md index dec758ce56f00..760d39e99b783 100644 --- a/railties/CHANGELOG.md +++ b/railties/CHANGELOG.md @@ -1,3 +1,11 @@ +* Optionally skip bundler-audit. + + Skips adding the `bin/bundler-audit` & `config/bundler-audit.yml` if the gem is not installed when `bin/rails app:update` runs. + + Passes an option to `--skip-bundler-audit` when new apps are generated & adds that same option to the `--minimal` generator flag. + + *Jill Klang* + * Show engine routes in `/rails/info/routes` as well. *Petrik de Heus* diff --git a/railties/lib/rails/commands/app/update_command.rb b/railties/lib/rails/commands/app/update_command.rb index 75685612cf916..792e8e9078b88 100644 --- a/railties/lib/rails/commands/app/update_command.rb +++ b/railties/lib/rails/commands/app/update_command.rb @@ -73,6 +73,7 @@ def generator_options skip_action_text: !defined?(ActionText::Engine), skip_action_cable: !defined?(ActionCable::Engine), skip_brakeman: skip_gem?("brakeman"), + skip_bundler_audit: skip_gem?("bundler-audit"), skip_rubocop: skip_gem?("rubocop"), skip_thruster: skip_gem?("thruster"), skip_test: !defined?(Rails::TestUnitRailtie), diff --git a/railties/lib/rails/generators/app_base.rb b/railties/lib/rails/generators/app_base.rb index 623c8ebad8755..eb885982ba0af 100644 --- a/railties/lib/rails/generators/app_base.rb +++ b/railties/lib/rails/generators/app_base.rb @@ -106,6 +106,9 @@ def self.add_shared_options_for(name) class_option :skip_brakeman, type: :boolean, default: nil, desc: "Skip brakeman setup" + class_option :skip_bundler_audit, type: :boolean, default: nil, + desc: "Skip bundler-audit setup" + class_option :skip_ci, type: :boolean, default: nil, desc: "Skip GitHub CI files" @@ -400,6 +403,10 @@ def skip_brakeman? options[:skip_brakeman] end + def skip_bundler_audit? + options[:skip_bundler_audit] + end + def skip_ci? options[:skip_ci] end diff --git a/railties/lib/rails/generators/rails/app/app_generator.rb b/railties/lib/rails/generators/rails/app/app_generator.rb index 4804ed66016d2..10c8d163a32a0 100644 --- a/railties/lib/rails/generators/rails/app/app_generator.rb +++ b/railties/lib/rails/generators/rails/app/app_generator.rb @@ -109,7 +109,7 @@ def app end def bin - exclude_pattern = Regexp.union([(/thrust/ if skip_thruster?), (/rubocop/ if skip_rubocop?), (/brakeman/ if skip_brakeman?)].compact) + exclude_pattern = Regexp.union([(/thrust/ if skip_thruster?), (/rubocop/ if skip_rubocop?), (/brakeman/ if skip_brakeman?), (/bundler-audit/ if skip_bundler_audit?)].compact) directory "bin", { exclude_pattern: exclude_pattern } do |content| "#{shebang}\n" + content end @@ -127,8 +127,8 @@ def config template "routes.rb" unless options[:update] template "application.rb" template "environment.rb" - template "bundler-audit.yml" - template "cable.yml" unless options[:update] || options[:skip_action_cable] + template "bundler-audit.yml" unless skip_bundler_audit? + template "cable.yml" unless options[:update] || skip_action_cable? template "ci.rb" template "puma.rb" template "storage.yml" unless options[:update] || skip_active_storage? @@ -153,7 +153,7 @@ def config_when_updating config - if !options[:skip_action_cable] && !action_cable_config_exist + if !skip_action_cable? && !action_cable_config_exist template "config/cable.yml" end @@ -177,7 +177,7 @@ def config_when_updating remove_file "config/initializers/cors.rb" end - if !bundle_audit_config_exist + if !skip_bundler_audit? && !bundle_audit_config_exist template "config/bundler-audit.yml" end @@ -317,6 +317,7 @@ class AppGenerator < AppBase :skip_active_storage, :skip_bootsnap, :skip_brakeman, + :skip_bundler_audit, :skip_ci, :skip_dev_gems, :skip_docker, diff --git a/railties/lib/rails/generators/rails/app/templates/Gemfile.tt b/railties/lib/rails/generators/rails/app/templates/Gemfile.tt index f6ee1cc037cb1..737150db88856 100644 --- a/railties/lib/rails/generators/rails/app/templates/Gemfile.tt +++ b/railties/lib/rails/generators/rails/app/templates/Gemfile.tt @@ -54,9 +54,11 @@ gem "image_processing", "~> 1.2" group :development, :test do # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem gem "debug", platforms: %i[ mri windows ], require: "debug/prelude" +<%- unless options.skip_bundler_audit? -%> # Audits gems for known security defects (use config/bundler-audit.yml to ignore issues) gem "bundler-audit", require: false +<%- end -%> <%- unless options.skip_brakeman? -%> # Static analysis for security vulnerabilities [https://brakemanscanner.org/] diff --git a/railties/lib/rails/generators/rails/app/templates/config/ci.rb.tt b/railties/lib/rails/generators/rails/app/templates/config/ci.rb.tt index fcc3ec877001e..ac526af3b9af1 100644 --- a/railties/lib/rails/generators/rails/app/templates/config/ci.rb.tt +++ b/railties/lib/rails/generators/rails/app/templates/config/ci.rb.tt @@ -6,7 +6,9 @@ CI.run do step "Style: Ruby", "bin/rubocop" <% end -%> +<% unless options.skip_bundler_audit? -%> step "Security: Gem audit", "bin/bundler-audit" +<% end -%> <% if using_node? -%> step "Security: Yarn vulnerability audit", "yarn audit" <% end -%> diff --git a/railties/lib/rails/generators/rails/app/templates/github/ci.yml.tt b/railties/lib/rails/generators/rails/app/templates/github/ci.yml.tt index bf1b0163ad41b..05229cd809a68 100644 --- a/railties/lib/rails/generators/rails/app/templates/github/ci.yml.tt +++ b/railties/lib/rails/generators/rails/app/templates/github/ci.yml.tt @@ -6,7 +6,7 @@ on: branches: [ <%= user_default_branch %> ] jobs: -<%- unless skip_brakeman? -%> +<%- unless skip_brakeman? && skip_bundler_audit? -%> scan_ruby: runs-on: ubuntu-latest @@ -19,11 +19,15 @@ jobs: with: bundler-cache: true + <%- unless skip_brakeman? %> - name: Scan for common Rails security vulnerabilities using static analysis run: bin/brakeman --no-pager + <% end %> + <%- unless skip_bundler_audit? -%> - name: Scan for known security vulnerabilities in gems used run: bin/bundler-audit + <% end %> <% end -%> <%- if using_importmap? -%> diff --git a/railties/lib/rails/generators/rails/plugin/plugin_generator.rb b/railties/lib/rails/generators/rails/plugin/plugin_generator.rb index ce2640cdb50f5..03a9f0a902e94 100644 --- a/railties/lib/rails/generators/rails/plugin/plugin_generator.rb +++ b/railties/lib/rails/generators/rails/plugin/plugin_generator.rb @@ -124,6 +124,7 @@ def generate_test_dummy(force = false) opts[:force] = force opts[:skip_thruster] = true opts[:skip_brakeman] = true + opts[:skip_bundler_audit] = true opts[:skip_bundle] = true opts[:skip_ci] = true opts[:skip_kamal] = true diff --git a/railties/test/application/bin_ci_test.rb b/railties/test/application/bin_ci_test.rb index 37fb22e211e87..46af73dc74d25 100644 --- a/railties/test/application/bin_ci_test.rb +++ b/railties/test/application/bin_ci_test.rb @@ -19,6 +19,7 @@ class BinCiTest < ActiveSupport::TestCase # Default steps assert_match(/bin\/rubocop/, content) assert_match(/bin\/brakeman/, content) + assert_match(/bin\/bundler-audit/, content) assert_match(/"bin\/rails test"$/, content) assert_match(/"bin\/rails test:system"$/, content) assert_match(/bin\/rails db:seed:replant/, content) diff --git a/railties/test/generators/app_generator_test.rb b/railties/test/generators/app_generator_test.rb index 9649daa66b2c9..f5390542b4c15 100644 --- a/railties/test/generators/app_generator_test.rb +++ b/railties/test/generators/app_generator_test.rb @@ -298,6 +298,16 @@ def test_app_update_preserves_skip_brakeman end end + def test_app_update_preserves_skip_bundler_audit + run_generator [ destination_root, "--skip-bundler-audit" ] + + FileUtils.cd(destination_root) do + assert_no_changes -> { File.exist?("bin/bundler-audit") } do + run_app_update + end + end + end + def test_app_update_preserves_skip_rubocop run_generator [ destination_root, "--skip-rubocop" ] @@ -655,6 +665,18 @@ def test_both_brakeman_and_rubocop_binstubs_are_skipped_if_required assert_no_file "bin/brakeman" end + def test_inclusion_of_bundler_audit + run_generator + assert_gem "bundler-audit" + end + + def test_bundler_audit_is_skipped_if_required + run_generator [destination_root, "--skip-bundler-audit"] + + assert_no_gem "bundler-audit" + assert_no_file "bin/bundler-audit" + end + def test_inclusion_of_ci_files run_generator assert_file ".github/workflows/ci.yml" do |yaml| @@ -1379,6 +1401,7 @@ def test_minimal_rails_app assert_option :skip_active_storage assert_option :skip_bootsnap assert_option :skip_brakeman + assert_option :skip_bundler_audit assert_option :skip_ci assert_option :skip_dev_gems assert_option :skip_docker diff --git a/railties/test/generators/plugin_generator_test.rb b/railties/test/generators/plugin_generator_test.rb index 3df3c51efd781..0c633fcbc4fad 100644 --- a/railties/test/generators/plugin_generator_test.rb +++ b/railties/test/generators/plugin_generator_test.rb @@ -39,7 +39,6 @@ test/dummy/app/views/layouts/mailer.text.erb test/dummy/app/views/pwa/manifest.json.erb test/dummy/app/views/pwa/service-worker.js - test/dummy/bin/bundler-audit test/dummy/bin/ci test/dummy/bin/dev test/dummy/bin/rails @@ -48,7 +47,6 @@ test/dummy/config.ru test/dummy/config/application.rb test/dummy/config/boot.rb - test/dummy/config/bundler-audit.yml test/dummy/config/cable.yml test/dummy/config/ci.rb test/dummy/config/database.yml From 03ce71ae93be762c7568de4b4809904563724988 Mon Sep 17 00:00:00 2001 From: Harsh Deep Date: Mon, 22 Sep 2025 19:31:00 -0400 Subject: [PATCH 0676/1075] Indent private methods with two spaces consistently and add a note about it I noticed that the tutorial had the most `private` methods have two extra spaces of indentation in almost all cases so I made this consistent. I also added a note that this tutorial is following this convention. While I've seen it quite often, I know there seems to be competing [conventions](https://fabiokung.com/2010/04/05/ruby-indentation-for-access-modifiers-and-their-sections/) around this. --- guides/source/getting_started.md | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/guides/source/getting_started.md b/guides/source/getting_started.md index 32d8515aeeae2..0624ad39a47fa 100644 --- a/guides/source/getting_started.md +++ b/guides/source/getting_started.md @@ -2075,14 +2075,13 @@ class SubscribersController < ApplicationController end private + def set_product + @product = Product.find(params[:product_id]) + end - def set_product - @product = Product.find(params[:product_id]) - end - - def subscriber_params - params.expect(subscriber: [ :email ]) - end + def subscriber_params + params.expect(subscriber: [ :email ]) + end end ``` @@ -2403,10 +2402,9 @@ class UnsubscribesController < ApplicationController end private - - def set_subscriber - @subscriber = Subscriber.find_by_token_for(:unsubscribe, params[:token]) - end + def set_subscriber + @subscriber = Subscriber.find_by_token_for(:unsubscribe, params[:token]) + end end ``` From 6b657665a327a7cf3178bb586f58d2f41ea63c97 Mon Sep 17 00:00:00 2001 From: Hartley McGuire Date: Tue, 23 Sep 2025 18:20:33 -0400 Subject: [PATCH 0677/1075] Pin mysql2 to 0.5.6 to fix CI It's currently segfaulting with prepared statements enabled --- Gemfile | 2 +- Gemfile.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile b/Gemfile index fbb6bddec6912..fa78813b2bfbd 100644 --- a/Gemfile +++ b/Gemfile @@ -155,7 +155,7 @@ platforms :ruby, :windows do group :db do gem "pg", "~> 1.3" - gem "mysql2", "~> 0.5" + gem "mysql2", "~> 0.5", "< 0.5.7" gem "trilogy", ">= 2.7.0" end end diff --git a/Gemfile.lock b/Gemfile.lock index e040b057afdca..a1c60f9303782 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -780,7 +780,7 @@ DEPENDENCIES minitest-ci minitest-retry msgpack (>= 1.7.0) - mysql2 (~> 0.5) + mysql2 (~> 0.5, < 0.5.7) nokogiri (>= 1.8.1, != 1.11.0) pg (~> 1.3) prism From 6b11d3c90c20411d6cb2ad1c961f80659709884d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Tue, 23 Sep 2025 22:02:46 +0000 Subject: [PATCH 0678/1075] Fix tests now that Propshaft::Server is a middleware --- Gemfile.lock | 20 ++++----- railties/test/commands/middleware_test.rb | 9 ++-- railties/test/commands/routes_test.rb | 55 ++++++++++------------- 3 files changed, 39 insertions(+), 45 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index e040b057afdca..791cc6491a94f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -400,24 +400,24 @@ GEM net-protocol net-ssh (7.3.0) nio4r (2.7.4) - nokogiri (1.18.9) + nokogiri (1.18.10) mini_portile2 (~> 2.8.2) racc (~> 1.4) - nokogiri (1.18.9-aarch64-linux-gnu) + nokogiri (1.18.10-aarch64-linux-gnu) racc (~> 1.4) - nokogiri (1.18.9-aarch64-linux-musl) + nokogiri (1.18.10-aarch64-linux-musl) racc (~> 1.4) - nokogiri (1.18.9-arm-linux-gnu) + nokogiri (1.18.10-arm-linux-gnu) racc (~> 1.4) - nokogiri (1.18.9-arm-linux-musl) + nokogiri (1.18.10-arm-linux-musl) racc (~> 1.4) - nokogiri (1.18.9-arm64-darwin) + nokogiri (1.18.10-arm64-darwin) racc (~> 1.4) - nokogiri (1.18.9-x86_64-darwin) + nokogiri (1.18.10-x86_64-darwin) racc (~> 1.4) - nokogiri (1.18.9-x86_64-linux-gnu) + nokogiri (1.18.10-x86_64-linux-gnu) racc (~> 1.4) - nokogiri (1.18.9-x86_64-linux-musl) + nokogiri (1.18.10-x86_64-linux-musl) racc (~> 1.4) os (1.1.4) ostruct (0.6.1) @@ -437,7 +437,7 @@ GEM prettyprint prettyprint (0.2.0) prism (1.4.0) - propshaft (1.2.0) + propshaft (1.3.1) actionpack (>= 7.0.0) activesupport (>= 7.0.0) rack diff --git a/railties/test/commands/middleware_test.rb b/railties/test/commands/middleware_test.rb index 0b3ddf0d5eb82..14405a2def4ff 100644 --- a/railties/test/commands/middleware_test.rb +++ b/railties/test/commands/middleware_test.rb @@ -29,6 +29,7 @@ def app "ActionDispatch::HostAuthorization", "Rack::Sendfile", "ActionDispatch::Static", + "Propshaft::Server", "ActionDispatch::Executor", "ActionDispatch::ServerTiming", "ActiveSupport::Cache::Strategy::LocalCache", @@ -64,6 +65,7 @@ def app "ActionDispatch::HostAuthorization", "Rack::Sendfile", "ActionDispatch::Static", + "Propshaft::Server", "ActionDispatch::Executor", "ActionDispatch::ServerTiming", "ActiveSupport::Cache::Strategy::LocalCache", @@ -98,6 +100,7 @@ def app "ActionDispatch::HostAuthorization", "Rack::Sendfile", "ActionDispatch::Static", + "Propshaft::Server", "ActionDispatch::Executor", "ActiveSupport::Cache::Strategy::LocalCache", "Rack::Runtime", @@ -314,14 +317,14 @@ def app test "Rails.cache does not respond to middleware" do add_to_config "config.cache_store = :memory_store, { timeout: 10 }" boot! - assert_equal "Rack::Runtime", middleware[4] + assert_equal "Rack::Runtime", middleware[5] assert_instance_of ActiveSupport::Cache::MemoryStore, Rails.cache end test "Rails.cache does respond to middleware" do boot! - assert_equal "ActiveSupport::Cache::Strategy::LocalCache", middleware[4] - assert_equal "Rack::Runtime", middleware[5] + assert_equal "ActiveSupport::Cache::Strategy::LocalCache", middleware[5] + assert_equal "Rack::Runtime", middleware[6] end test "insert middleware before" do diff --git a/railties/test/commands/routes_test.rb b/railties/test/commands/routes_test.rb index 78c921373db81..3f96d914b6688 100644 --- a/railties/test/commands/routes_test.rb +++ b/railties/test/commands/routes_test.rb @@ -205,7 +205,6 @@ class Rails::Command::RoutesTest < ActiveSupport::TestCase assert_equal <<~MESSAGE, run_routes_command Prefix Verb URI Pattern Controller#Action - /assets Propshaft::Server rails_postmark_inbound_emails POST /rails/action_mailbox/postmark/inbound_emails(.:format) action_mailbox/ingresses/postmark/inbound_emails#create rails_relay_inbound_emails POST /rails/action_mailbox/relay/inbound_emails(.:format) action_mailbox/ingresses/relay/inbound_emails#create rails_sendgrid_inbound_emails POST /rails/action_mailbox/sendgrid/inbound_emails(.:format) action_mailbox/ingresses/sendgrid/inbound_emails#create @@ -249,160 +248,152 @@ class Rails::Command::RoutesTest < ActiveSupport::TestCase rails_gem_root = File.expand_path("../../../../", __FILE__) - # rubocop:disable Layout/TrailingWhitespace assert_equal <<~MESSAGE, output --[ Route 1 ]-------------- - Prefix | - Verb | - URI | /assets - Controller#Action | Propshaft::Server - Source Location | propshaft (X.X.X) lib/propshaft/railtie.rb:XX - --[ Route 2 ]-------------- Prefix | cart Verb | GET URI | /cart(.:format) Controller#Action | cart#show Source Location | #{app_path}/config/routes.rb:XX - --[ Route 3 ]-------------- + --[ Route 2 ]-------------- Prefix | rails_postmark_inbound_emails Verb | POST URI | /rails/action_mailbox/postmark/inbound_emails(.:format) Controller#Action | action_mailbox/ingresses/postmark/inbound_emails#create Source Location | #{rails_gem_root}/actionmailbox/config/routes.rb:XX - --[ Route 4 ]-------------- + --[ Route 3 ]-------------- Prefix | rails_relay_inbound_emails Verb | POST URI | /rails/action_mailbox/relay/inbound_emails(.:format) Controller#Action | action_mailbox/ingresses/relay/inbound_emails#create Source Location | #{rails_gem_root}/actionmailbox/config/routes.rb:XX - --[ Route 5 ]-------------- + --[ Route 4 ]-------------- Prefix | rails_sendgrid_inbound_emails Verb | POST URI | /rails/action_mailbox/sendgrid/inbound_emails(.:format) Controller#Action | action_mailbox/ingresses/sendgrid/inbound_emails#create Source Location | #{rails_gem_root}/actionmailbox/config/routes.rb:XX - --[ Route 6 ]-------------- + --[ Route 5 ]-------------- Prefix | rails_mandrill_inbound_health_check Verb | GET URI | /rails/action_mailbox/mandrill/inbound_emails(.:format) Controller#Action | action_mailbox/ingresses/mandrill/inbound_emails#health_check Source Location | #{rails_gem_root}/actionmailbox/config/routes.rb:XX - --[ Route 7 ]-------------- + --[ Route 6 ]-------------- Prefix | rails_mandrill_inbound_emails Verb | POST URI | /rails/action_mailbox/mandrill/inbound_emails(.:format) Controller#Action | action_mailbox/ingresses/mandrill/inbound_emails#create Source Location | #{rails_gem_root}/actionmailbox/config/routes.rb:XX - --[ Route 8 ]-------------- + --[ Route 7 ]-------------- Prefix | rails_mailgun_inbound_emails Verb | POST URI | /rails/action_mailbox/mailgun/inbound_emails/mime(.:format) Controller#Action | action_mailbox/ingresses/mailgun/inbound_emails#create Source Location | #{rails_gem_root}/actionmailbox/config/routes.rb:XX - --[ Route 9 ]-------------- + --[ Route 8 ]-------------- Prefix | rails_conductor_inbound_emails Verb | GET URI | /rails/conductor/action_mailbox/inbound_emails(.:format) Controller#Action | rails/conductor/action_mailbox/inbound_emails#index Source Location | #{rails_gem_root}/actionmailbox/config/routes.rb:XX - --[ Route 10 ]------------- + --[ Route 9 ]-------------- Prefix |#{" "} Verb | POST URI | /rails/conductor/action_mailbox/inbound_emails(.:format) Controller#Action | rails/conductor/action_mailbox/inbound_emails#create Source Location | #{rails_gem_root}/actionmailbox/config/routes.rb:XX - --[ Route 11 ]------------- + --[ Route 10 ]------------- Prefix | new_rails_conductor_inbound_email Verb | GET URI | /rails/conductor/action_mailbox/inbound_emails/new(.:format) Controller#Action | rails/conductor/action_mailbox/inbound_emails#new Source Location | #{rails_gem_root}/actionmailbox/config/routes.rb:XX - --[ Route 12 ]------------- + --[ Route 11 ]------------- Prefix | rails_conductor_inbound_email Verb | GET URI | /rails/conductor/action_mailbox/inbound_emails/:id(.:format) Controller#Action | rails/conductor/action_mailbox/inbound_emails#show Source Location | #{rails_gem_root}/actionmailbox/config/routes.rb:XX - --[ Route 13 ]------------- + --[ Route 12 ]------------- Prefix | new_rails_conductor_inbound_email_source Verb | GET URI | /rails/conductor/action_mailbox/inbound_emails/sources/new(.:format) Controller#Action | rails/conductor/action_mailbox/inbound_emails/sources#new Source Location | #{rails_gem_root}/actionmailbox/config/routes.rb:XX - --[ Route 14 ]------------- + --[ Route 13 ]------------- Prefix | rails_conductor_inbound_email_sources Verb | POST URI | /rails/conductor/action_mailbox/inbound_emails/sources(.:format) Controller#Action | rails/conductor/action_mailbox/inbound_emails/sources#create Source Location | #{rails_gem_root}/actionmailbox/config/routes.rb:XX - --[ Route 15 ]------------- + --[ Route 14 ]------------- Prefix | rails_conductor_inbound_email_reroute Verb | POST URI | /rails/conductor/action_mailbox/:inbound_email_id/reroute(.:format) Controller#Action | rails/conductor/action_mailbox/reroutes#create Source Location | #{rails_gem_root}/actionmailbox/config/routes.rb:XX - --[ Route 16 ]------------- + --[ Route 15 ]------------- Prefix | rails_conductor_inbound_email_incinerate Verb | POST URI | /rails/conductor/action_mailbox/:inbound_email_id/incinerate(.:format) Controller#Action | rails/conductor/action_mailbox/incinerates#create Source Location | #{rails_gem_root}/actionmailbox/config/routes.rb:XX - --[ Route 17 ]------------- + --[ Route 16 ]------------- Prefix | rails_service_blob Verb | GET URI | /rails/active_storage/blobs/redirect/:signed_id/*filename(.:format) Controller#Action | active_storage/blobs/redirect#show Source Location | #{rails_gem_root}/activestorage/config/routes.rb:XX - --[ Route 18 ]------------- + --[ Route 17 ]------------- Prefix | rails_service_blob_proxy Verb | GET URI | /rails/active_storage/blobs/proxy/:signed_id/*filename(.:format) Controller#Action | active_storage/blobs/proxy#show Source Location | #{rails_gem_root}/activestorage/config/routes.rb:XX - --[ Route 19 ]------------- + --[ Route 18 ]------------- Prefix |#{" "} Verb | GET URI | /rails/active_storage/blobs/:signed_id/*filename(.:format) Controller#Action | active_storage/blobs/redirect#show Source Location | #{rails_gem_root}/activestorage/config/routes.rb:XX - --[ Route 20 ]------------- + --[ Route 19 ]------------- Prefix | rails_blob_representation Verb | GET URI | /rails/active_storage/representations/redirect/:signed_blob_id/:variation_key/*filename(.:format) Controller#Action | active_storage/representations/redirect#show Source Location | #{rails_gem_root}/activestorage/config/routes.rb:XX - --[ Route 21 ]------------- + --[ Route 20 ]------------- Prefix | rails_blob_representation_proxy Verb | GET URI | /rails/active_storage/representations/proxy/:signed_blob_id/:variation_key/*filename(.:format) Controller#Action | active_storage/representations/proxy#show Source Location | #{rails_gem_root}/activestorage/config/routes.rb:XX - --[ Route 22 ]------------- + --[ Route 21 ]------------- Prefix |#{" "} Verb | GET URI | /rails/active_storage/representations/:signed_blob_id/:variation_key/*filename(.:format) Controller#Action | active_storage/representations/redirect#show Source Location | #{rails_gem_root}/activestorage/config/routes.rb:XX - --[ Route 23 ]------------- + --[ Route 22 ]------------- Prefix | rails_disk_service Verb | GET URI | /rails/active_storage/disk/:encoded_key/*filename(.:format) Controller#Action | active_storage/disk#show Source Location | #{rails_gem_root}/activestorage/config/routes.rb:XX - --[ Route 24 ]------------- + --[ Route 23 ]------------- Prefix | update_rails_disk_service Verb | PUT URI | /rails/active_storage/disk/:encoded_token(.:format) Controller#Action | active_storage/disk#update Source Location | #{rails_gem_root}/activestorage/config/routes.rb:XX - --[ Route 25 ]------------- + --[ Route 24 ]------------- Prefix | rails_direct_uploads Verb | POST URI | /rails/active_storage/direct_uploads(.:format) Controller#Action | active_storage/direct_uploads#create Source Location | #{rails_gem_root}/activestorage/config/routes.rb:XX MESSAGE - # rubocop:enable Layout/TrailingWhitespace end test "rails routes with unused option" do From ebb01c5a8ac677ca3263c8960d0e097fb1272938 Mon Sep 17 00:00:00 2001 From: Gannon McGibbon Date: Tue, 23 Sep 2025 16:04:07 -0500 Subject: [PATCH 0679/1075] Emit only one structured event per notification event Action Mailer and Active Job event subscribers now emit one event type per notification instead of differently named ones based on the payload. Recorded event durations are also all now rounded to 2 decimal places and named duration. --- actionmailer/CHANGELOG.md | 2 - .../structured_event_subscriber.rb | 33 ++- .../test/structured_event_subscriber_test.rb | 6 +- .../structured_event_subscriber.rb | 16 +- activejob/CHANGELOG.md | 7 - .../active_job/structured_event_subscriber.rb | 210 +++++++----------- .../cases/structured_event_subscriber_test.rb | 28 ++- 7 files changed, 121 insertions(+), 181 deletions(-) diff --git a/actionmailer/CHANGELOG.md b/actionmailer/CHANGELOG.md index c8bf367514896..d8bc9904b2dd3 100644 --- a/actionmailer/CHANGELOG.md +++ b/actionmailer/CHANGELOG.md @@ -1,7 +1,5 @@ * Add structured events for Action Mailer: - - `action_mailer.delivery_error` - `action_mailer.delivered` - - `action_mailer.delivery_skipped` - `action_mailer.processed` *Gannon McGibbon* diff --git a/actionmailer/lib/action_mailer/structured_event_subscriber.rb b/actionmailer/lib/action_mailer/structured_event_subscriber.rb index d2a730e221f1d..cc5811a255fd2 100644 --- a/actionmailer/lib/action_mailer/structured_event_subscriber.rb +++ b/actionmailer/lib/action_mailer/structured_event_subscriber.rb @@ -6,25 +6,20 @@ module ActionMailer class StructuredEventSubscriber < ActiveSupport::StructuredEventSubscriber # :nodoc: # An email was delivered. def deliver(event) - if (exception = event.payload[:exception_object]) - emit_debug_event("action_mailer.delivery_error", - message_id: event.payload[:message_id], - exception_class: exception.class.name, - exception_message: exception.message, - mail: event.payload[:mail], - ) - elsif event.payload[:perform_deliveries] - emit_debug_event("action_mailer.delivered", - message_id: event.payload[:message_id], - duration: event.duration.round(1), - mail: event.payload[:mail], - ) - else - emit_debug_event("action_mailer.delivery_skipped", - message_id: event.payload[:message_id], - mail: event.payload[:mail], - ) + exception = event.payload[:exception_object] + payload = { + message_id: event.payload[:message_id], + duration: event.duration.round(2), + mail: event.payload[:mail], + perform_deliveries: event.payload[:perform_deliveries], + } + + if exception + payload[:exception_class] = exception.class.name + payload[:exception_message] = exception.message end + + emit_debug_event("action_mailer.delivered", payload) end debug_only :deliver @@ -33,7 +28,7 @@ def process(event) emit_debug_event("action_mailer.processed", mailer: event.payload[:mailer], action: event.payload[:action], - duration: event.duration.round(1), + duration: event.duration.round(2), ) end debug_only :process diff --git a/actionmailer/test/structured_event_subscriber_test.rb b/actionmailer/test/structured_event_subscriber_test.rb index b225c912f7455..e62260c8ea5ef 100644 --- a/actionmailer/test/structured_event_subscriber_test.rb +++ b/actionmailer/test/structured_event_subscriber_test.rb @@ -25,7 +25,7 @@ def run(*) end def test_deliver_is_notified - event = assert_event_reported("action_mailer.delivered", payload: { message_id: "123@abc", mail: /.*/ }) do + event = assert_event_reported("action_mailer.delivered", payload: { message_id: "123@abc", mail: /.*/, perform_deliveries: true }) do BaseMailer.welcome(message_id: "123@abc").deliver_now end @@ -35,7 +35,7 @@ def test_deliver_is_notified end def test_deliver_message_when_perform_deliveries_is_false - assert_event_reported("action_mailer.delivery_skipped", payload: { message_id: "123@abc", mail: /.*/ }) do + assert_event_reported("action_mailer.delivered", payload: { message_id: "123@abc", mail: /.*/, perform_deliveries: false }) do BaseMailer.welcome_without_deliveries(message_id: "123@abc").deliver_now end ensure @@ -47,7 +47,7 @@ def test_deliver_message_when_exception_happened BaseMailer.delivery_method = BogusDelivery payload = { message_id: "123@abc", mail: /.*/, exception_class: "RuntimeError", exception_message: "failed" } - assert_event_reported("action_mailer.delivery_error", payload:) do + assert_event_reported("action_mailer.delivered", payload:) do assert_raises(RuntimeError) { BaseMailer.welcome(message_id: "123@abc").deliver_now } end ensure diff --git a/actionview/lib/action_view/structured_event_subscriber.rb b/actionview/lib/action_view/structured_event_subscriber.rb index bd7d8025bf92e..79c001239a1eb 100644 --- a/actionview/lib/action_view/structured_event_subscriber.rb +++ b/actionview/lib/action_view/structured_event_subscriber.rb @@ -15,8 +15,8 @@ def render_template(event) emit_debug_event("action_view.render_template", identifier: from_rails_root(event.payload[:identifier]), layout: from_rails_root(event.payload[:layout]), - duration: event.duration.round(1), - gc: event.gc_time.round(1), + duration: event.duration.round(2), + gc: event.gc_time.round(2), ) end debug_only :render_template @@ -25,8 +25,8 @@ def render_partial(event) emit_debug_event("action_view.render_partial", identifier: from_rails_root(event.payload[:identifier]), layout: from_rails_root(event.payload[:layout]), - duration: event.duration.round(1), - gc: event.gc_time.round(1), + duration: event.duration.round(2), + gc: event.gc_time.round(2), cache_hit: event.payload[:cache_hit], ) end @@ -35,8 +35,8 @@ def render_partial(event) def render_layout(event) emit_event("action_view.render_layout", identifier: from_rails_root(event.payload[:identifier]), - duration: event.duration.round(1), - gc: event.gc_time.round(1), + duration: event.duration.round(2), + gc: event.gc_time.round(2), ) end debug_only :render_layout @@ -45,8 +45,8 @@ def render_collection(event) emit_debug_event("action_view.render_collection", identifier: from_rails_root(event.payload[:identifier] || "templates"), layout: from_rails_root(event.payload[:layout]), - duration: event.duration.round(1), - gc: event.gc_time.round(1), + duration: event.duration.round(2), + gc: event.gc_time.round(2), cache_hits: event.payload[:cache_hits], count: event.payload[:count], ) diff --git a/activejob/CHANGELOG.md b/activejob/CHANGELOG.md index c52a7e119fee2..5c1085e38962d 100644 --- a/activejob/CHANGELOG.md +++ b/activejob/CHANGELOG.md @@ -1,11 +1,7 @@ * Add structured events for Active Job: - - `active_job.enqueue_failed` - - `active_job.enqueue_aborted` - `active_job.enqueued` - `active_job.bulk_enqueued` - `active_job.started` - - `active_job.failed` - - `active_job.aborted` - `active_job.completed` - `active_job.retry_scheduled` - `active_job.retry_stopped` @@ -13,10 +9,7 @@ - `active_job.interrupt` - `active_job.resume` - `active_job.step_skipped` - - `active_job.step_resumed` - `active_job.step_started` - - `active_job.step_interrupted` - - `active_job.step_errored` - `active_job.step` *Adrianna Chang* diff --git a/activejob/lib/active_job/structured_event_subscriber.rb b/activejob/lib/active_job/structured_event_subscriber.rb index ea748c6e5f34d..764fdada2e374 100644 --- a/activejob/lib/active_job/structured_event_subscriber.rb +++ b/activejob/lib/active_job/structured_event_subscriber.rb @@ -6,67 +6,47 @@ module ActiveJob class StructuredEventSubscriber < ActiveSupport::StructuredEventSubscriber # :nodoc: def enqueue(event) job = event.payload[:job] - ex = event.payload[:exception_object] || job.enqueue_error - - if ex - emit_event("active_job.enqueue_failed", - job_class: job.class.name, - job_id: job.job_id, - queue: job.queue_name, - exception_class: ex.class.name, - exception_message: ex.message - ) - elsif event.payload[:aborted] - emit_event("active_job.enqueue_aborted", - job_class: job.class.name, - job_id: job.job_id, - queue: job.queue_name - ) - else - payload = { - job_class: job.class.name, - job_id: job.job_id, - queue: job.queue_name, - } - if job.class.log_arguments? - payload[:arguments] = job.arguments - end - emit_event("active_job.enqueued", payload) + exception = event.payload[:exception_object] || job.enqueue_error + payload = { + job_class: job.class.name, + job_id: job.job_id, + queue: job.queue_name, + aborted: event.payload[:aborted], + } + + if exception + payload[:exception_class] = exception.class.name + payload[:exception_message] = exception.message end + + if job.class.log_arguments? + payload[:arguments] = job.arguments + end + + emit_event("active_job.enqueued", payload) end def enqueue_at(event) job = event.payload[:job] - ex = event.payload[:exception_object] || job.enqueue_error - - if ex - emit_event("active_job.enqueue_failed", - job_class: job.class.name, - job_id: job.job_id, - queue: job.queue_name, - scheduled_at: job.scheduled_at, - exception_class: ex.class.name, - exception_message: ex.message - ) - elsif event.payload[:aborted] - emit_event("active_job.enqueue_aborted", - job_class: job.class.name, - job_id: job.job_id, - queue: job.queue_name, - scheduled_at: job.scheduled_at - ) - else - payload = { - job_class: job.class.name, - job_id: job.job_id, - queue: job.queue_name, - scheduled_at: job.scheduled_at, - } - if job.class.log_arguments? - payload[:arguments] = job.arguments - end - emit_event("active_job.enqueued", payload) + exception = event.payload[:exception_object] || job.enqueue_error + payload = { + job_class: job.class.name, + job_id: job.job_id, + queue: job.queue_name, + scheduled_at: job.scheduled_at, + aborted: event.payload[:aborted], + } + + if exception + payload[:exception_class] = exception.class.name + payload[:exception_message] = exception.message + end + + if job.class.log_arguments? + payload[:arguments] = job.arguments end + + emit_event("active_job.enqueued", payload) end def enqueue_all(event) @@ -100,37 +80,26 @@ def perform_start(event) def perform(event) job = event.payload[:job] - ex = event.payload[:exception_object] - - if ex - emit_event("active_job.failed", - job_class: job.class.name, - job_id: job.job_id, - queue: job.queue_name, - duration_ms: event.duration.round(2), - exception_class: ex.class.name, - exception_message: ex.message - ) - elsif event.payload[:aborted] - emit_event("active_job.aborted", - job_class: job.class.name, - job_id: job.job_id, - queue: job.queue_name, - duration_ms: event.duration.round(2) - ) - else - emit_event("active_job.completed", - job_class: job.class.name, - job_id: job.job_id, - queue: job.queue_name, - duration_ms: event.duration.round(2) - ) + exception = event.payload[:exception_object] + payload = { + job_class: job.class.name, + job_id: job.job_id, + queue: job.queue_name, + aborted: event.payload[:aborted], + duration: event.duration.round(2), + } + + if exception + payload[:exception_class] = exception.class.name + payload[:exception_message] = exception.message end + + emit_event("active_job.completed", payload) end def enqueue_retry(event) job = event.payload[:job] - ex = event.payload[:error] + exception = event.payload[:error] wait = event.payload[:wait] emit_event("active_job.retry_scheduled", @@ -138,33 +107,33 @@ def enqueue_retry(event) job_id: job.job_id, executions: job.executions, wait_seconds: wait.to_i, - exception_class: ex&.class&.name, - exception_message: ex&.message + exception_class: exception&.class&.name, + exception_message: exception&.message ) end def retry_stopped(event) job = event.payload[:job] - ex = event.payload[:error] + exception = event.payload[:error] emit_event("active_job.retry_stopped", job_class: job.class.name, job_id: job.job_id, executions: job.executions, - exception_class: ex.class.name, - exception_message: ex.message + exception_class: exception.class.name, + exception_message: exception.message ) end def discard(event) job = event.payload[:job] - ex = event.payload[:error] + exception = event.payload[:error] emit_event("active_job.discarded", job_class: job.class.name, job_id: job.job_id, - exception_class: ex.class.name, - exception_message: ex.message + exception_class: exception.class.name, + exception_message: exception.message ) end @@ -207,53 +176,34 @@ def step_started(event) job = event.payload[:job] step = event.payload[:step] - if step.resumed? - emit_event("active_job.step_resumed", - job_class: job.class.name, - job_id: job.job_id, - step: step.name, - cursor: step.cursor, - ) - else - emit_event("active_job.step_started", - job_class: job.class.name, - job_id: job.job_id, - step: step.name, - ) - end + emit_event("active_job.step_started", + job_class: job.class.name, + job_id: job.job_id, + step: step.name, + cursor: step.cursor, + resumed: step.resumed?, + ) end def step(event) job = event.payload[:job] step = event.payload[:step] - ex = event.payload[:exception_object] - - if event.payload[:interrupted] - emit_event("active_job.step_interrupted", - job_class: job.class.name, - job_id: job.job_id, - step: step.name, - cursor: step.cursor, - duration: event.duration.round(2), - ) - elsif ex - emit_event("active_job.step_errored", - job_class: job.class.name, - job_id: job.job_id, - step: step.name, - cursor: step.cursor, - duration: event.duration.round(2), - exception_class: ex.class.name, - exception_message: ex.message, - ) - else - emit_event("active_job.step", - job_class: job.class.name, - job_id: job.job_id, - step: step.name, - duration: event.duration.round(2), - ) + exception = event.payload[:exception_object] + payload = { + job_class: job.class.name, + job_id: job.job_id, + step: step.name, + cursor: step.cursor, + interrupted: event.payload[:interrupted], + duration: event.duration.round(2), + } + + if exception + payload[:exception_class] = exception.class.name + payload[:exception_message] = exception.message end + + emit_event("active_job.step", payload) end end end diff --git a/activejob/test/cases/structured_event_subscriber_test.rb b/activejob/test/cases/structured_event_subscriber_test.rb index b0848df31a27c..4d31b1ef26d80 100644 --- a/activejob/test/cases/structured_event_subscriber_test.rb +++ b/activejob/test/cases/structured_event_subscriber_test.rb @@ -142,15 +142,15 @@ def test_perform_completed_job end assert event[:payload][:job_id].present? - assert event[:payload][:duration_ms].is_a?(Numeric) + assert event[:payload][:duration].is_a?(Numeric) end def test_perform_failed_job - event = assert_event_reported("active_job.failed", payload: { + event = assert_event_reported("active_job.completed", payload: { job_class: TestJob.name, queue: "default", exception_class: "StandardError", - exception_message: "Something went wrong" + exception_message: "Something went wrong", }) do assert_raises(StandardError) do TestJob.perform_now("raise_error") @@ -158,7 +158,7 @@ def test_perform_failed_job end assert event[:payload][:job_id].present? - assert event[:payload][:duration_ms].is_a?(Numeric) + assert event[:payload][:duration].is_a?(Numeric) end def test_enqueue_failed_job @@ -168,7 +168,7 @@ def test_enqueue_failed_job end end - assert_event_reported("active_job.enqueue_failed", payload: { + assert_event_reported("active_job.enqueued", payload: { job_class: failing_enqueue_job_class.name, queue: "default", exception_class: "StandardError", @@ -187,9 +187,10 @@ def test_enqueue_aborted_job end end - assert_event_reported("active_job.enqueue_aborted", payload: { + assert_event_reported("active_job.enqueued", payload: { job_class: aborting_enqueue_job_class.name, - queue: "default" + queue: "default", + aborted: true, }) do aborting_enqueue_job_class.perform_later end @@ -202,9 +203,10 @@ def test_perform_aborted_job end end - assert_event_reported("active_job.aborted", payload: { + assert_event_reported("active_job.completed", payload: { job_class: aborting_perform_job_class.name, - queue: "default" + queue: "default", + aborted: true, }) do aborting_perform_job_class.perform_now end @@ -288,9 +290,10 @@ def test_step_skipped_job end def test_step_resumed_job - event = assert_event_reported("active_job.step_resumed", payload: { + event = assert_event_reported("active_job.step_started", payload: { job_class: ContinuationJob.name, step: :step, + resumed: true, }) do ContinuationJob.perform_later(action: :resume_step) end @@ -310,10 +313,11 @@ def test_step_started_job end def test_step_interrupted_job - event = assert_event_reported("active_job.step_interrupted", payload: { + event = assert_event_reported("active_job.step", payload: { job_class: ContinuationJob.name, step: :interrupt_step, cursor: nil, + interrupted: true, }) do ContinuationJob.perform_now(action: :interrupt_step) end @@ -323,7 +327,7 @@ def test_step_interrupted_job end def test_step_errored_job - event = assert_event_reported("active_job.step_errored", payload: { + event = assert_event_reported("active_job.step", payload: { job_class: ContinuationJob.name, step: :error_step, cursor: nil, From 971339231676b57da3aa02d87a95e07c0df5d0d8 Mon Sep 17 00:00:00 2001 From: Gannon McGibbon Date: Tue, 23 Sep 2025 16:09:38 -0500 Subject: [PATCH 0680/1075] Refactor action controller fragment events Remove unnecessary metaprogramming. --- .../structured_event_subscriber.rb | 42 ++++++++++++------- 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/actionpack/lib/action_controller/structured_event_subscriber.rb b/actionpack/lib/action_controller/structured_event_subscriber.rb index 5061495da9724..264bd8ebdbe86 100644 --- a/actionpack/lib/action_controller/structured_event_subscriber.rb +++ b/actionpack/lib/action_controller/structured_event_subscriber.rb @@ -82,22 +82,34 @@ def unpermitted_parameters(event) end debug_only :unpermitted_parameters - %w(write_fragment read_fragment exist_fragment? expire_fragment).each do |method| - class_eval <<-METHOD, __FILE__, __LINE__ + 1 - # frozen_string_literal: true - def #{method}(event) - return unless ActionController::Base.enable_fragment_cache_logging - - key = ActiveSupport::Cache.expand_cache_key(event.payload[:key] || event.payload[:path]) - - emit_event("action_controller.fragment_cache", - method: "#{method}", - key: key, - duration_ms: event.duration.round(1) - ) - end - METHOD + def write_fragment(event) + fragment_cache(__method__, event) end + + def read_fragment(event) + fragment_cache(__method__, event) + end + + def exist_fragment?(event) + fragment_cache(__method__, event) + end + + def expire_fragment(event) + fragment_cache(__method__, event) + end + + private + def fragment_cache(method_name, event) + return unless ActionController::Base.enable_fragment_cache_logging + + key = ActiveSupport::Cache.expand_cache_key(event.payload[:key] || event.payload[:path]) + + emit_event("action_controller.fragment_cache", + method: "#{method_name}", + key: key, + duration_ms: event.duration.round(1) + ) + end end end From 3debcaca38c4a78b93bf4b2e1d22bcb132db291c Mon Sep 17 00:00:00 2001 From: Gannon McGibbon Date: Tue, 23 Sep 2025 18:18:25 -0500 Subject: [PATCH 0681/1075] Remove ActiveSupport::Subscriber#publish_event in favor of call The methods are exactly the same, so let's get rid of the nodoc one. --- activesupport/lib/active_support/log_subscriber.rb | 6 ------ .../active_support/structured_event_subscriber.rb | 6 ------ activesupport/lib/active_support/subscriber.rb | 5 ----- .../test/structured_event_subscriber_test.rb | 12 ------------ 4 files changed, 29 deletions(-) diff --git a/activesupport/lib/active_support/log_subscriber.rb b/activesupport/lib/active_support/log_subscriber.rb index 4e0db5225c457..42a56218ca353 100644 --- a/activesupport/lib/active_support/log_subscriber.rb +++ b/activesupport/lib/active_support/log_subscriber.rb @@ -149,12 +149,6 @@ def call(event) log_exception(event.name, e) end - def publish_event(event) - super if logger - rescue => e - log_exception(event.name, e) - end - attr_writer :event_levels # :nodoc: private diff --git a/activesupport/lib/active_support/structured_event_subscriber.rb b/activesupport/lib/active_support/structured_event_subscriber.rb index bea5c941b7f89..513919d5dd534 100644 --- a/activesupport/lib/active_support/structured_event_subscriber.rb +++ b/activesupport/lib/active_support/structured_event_subscriber.rb @@ -91,12 +91,6 @@ def call(event) handle_event_error(event.name, e) end - def publish_event(event) - super - rescue => e - handle_event_error(event.name, e) - end - private def handle_event_error(name, error) ActiveSupport.error_reporter.report(error, source: name) diff --git a/activesupport/lib/active_support/subscriber.rb b/activesupport/lib/active_support/subscriber.rb index 19c28906abdce..cf0ed610eada0 100644 --- a/activesupport/lib/active_support/subscriber.rb +++ b/activesupport/lib/active_support/subscriber.rb @@ -137,10 +137,5 @@ def call(event) method = event.name[0, event.name.index(".")] send(method, event) end - - def publish_event(event) # :nodoc: - method = event.name[0, event.name.index(".")] - send(method, event) - end end end diff --git a/activesupport/test/structured_event_subscriber_test.rb b/activesupport/test/structured_event_subscriber_test.rb index ca328ae373a40..a33341289398d 100644 --- a/activesupport/test/structured_event_subscriber_test.rb +++ b/activesupport/test/structured_event_subscriber_test.rb @@ -78,18 +78,6 @@ def test_call_handles_errors assert_equal "error_event.test", error_report.source end - def test_publish_event_handles_errors - ActiveSupport::StructuredEventSubscriber.attach_to :test, @subscriber - - event = ActiveSupport::Notifications::Event.new("error_event.test", Time.current, Time.current, "123", {}) - - error_report = assert_error_reported(NoMethodError) do - @subscriber.publish_event(event) - end - assert_match(/undefined method (`|')error_event'/, error_report.error.message) - assert_equal "error_event.test", error_report.source - end - def test_debug_only_methods ActiveSupport::StructuredEventSubscriber.attach_to :test, @subscriber From 227c653beee0418835275c3914845e9b2d2f4abc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Tue, 23 Sep 2025 23:54:22 +0000 Subject: [PATCH 0682/1075] Use sdoc 2.6.4 --- Gemfile | 2 +- Gemfile.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Gemfile b/Gemfile index fa78813b2bfbd..10b027fd2458c 100644 --- a/Gemfile +++ b/Gemfile @@ -62,7 +62,7 @@ group :mdl do end group :doc do - gem "sdoc" + gem "sdoc", "~> 2.6.4" gem "redcarpet", "~> 3.6.1", platforms: :ruby gem "w3c_validators", "~> 1.3.6" gem "rouge" diff --git a/Gemfile.lock b/Gemfile.lock index 518a7b53e9e75..6f8af96e771bb 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -584,7 +584,7 @@ GEM google-protobuf (~> 4.29) sass-embedded (1.83.4-x86_64-linux-musl) google-protobuf (~> 4.29) - sdoc (2.6.3) + sdoc (2.6.4) rdoc (>= 5.0) securerandom (0.4.1) selenium-webdriver (4.32.0) @@ -807,7 +807,7 @@ DEPENDENCIES rubocop-rails rubocop-rails-omakase rubyzip (~> 2.0) - sdoc + sdoc (~> 2.6.4) selenium-webdriver (>= 4.20.0) sidekiq sneakers From 78857a394515c74e77db81da75f41f74d474b8e1 Mon Sep 17 00:00:00 2001 From: Gannon McGibbon Date: Tue, 23 Sep 2025 17:45:19 -0500 Subject: [PATCH 0683/1075] Introduce with_debug_event_reporting to enable event reporter debug mode The previous way to enable debug mode is by using `#with_debug` on the event reporter itself, which is too verbose. This new helper will help clear up any confusion on how to test debug events. --- .../test/structured_event_subscriber_test.rb | 2 +- .../structured_event_subscriber_test.rb | 2 +- .../structured_event_subscriber_test.rb | 36 +++++++++---------- .../cases/structured_event_subscriber_test.rb | 2 +- .../test/structured_event_subscriber_test.rb | 6 ++-- activesupport/CHANGELOG.md | 9 +++++ .../testing/event_reporter_assertions.rb | 10 ++++++ .../test/structured_event_subscriber_test.rb | 4 +-- 8 files changed, 45 insertions(+), 26 deletions(-) diff --git a/actionmailer/test/structured_event_subscriber_test.rb b/actionmailer/test/structured_event_subscriber_test.rb index e62260c8ea5ef..e6cdefc96a0ef 100644 --- a/actionmailer/test/structured_event_subscriber_test.rb +++ b/actionmailer/test/structured_event_subscriber_test.rb @@ -19,7 +19,7 @@ def deliver!(mail) end def run(*) - ActiveSupport.event_reporter.with_debug do + with_debug_event_reporting do super end end diff --git a/actionpack/test/controller/structured_event_subscriber_test.rb b/actionpack/test/controller/structured_event_subscriber_test.rb index 9bcca9293a87b..22d5c6cc6524f 100644 --- a/actionpack/test/controller/structured_event_subscriber_test.rb +++ b/actionpack/test/controller/structured_event_subscriber_test.rb @@ -129,7 +129,7 @@ def test_send_file end def test_unpermitted_parameters - ActiveSupport.event_reporter.with_debug do + with_debug_event_reporting do assert_event_reported("action_controller.unpermitted_parameters", payload: { controller: Another::StructuredEventSubscribersController.name, action: "unpermitted_parameters", diff --git a/actionview/test/template/structured_event_subscriber_test.rb b/actionview/test/template/structured_event_subscriber_test.rb index 1e30c64d293cd..bfbe1259acad2 100644 --- a/actionview/test/template/structured_event_subscriber_test.rb +++ b/actionview/test/template/structured_event_subscriber_test.rb @@ -35,7 +35,7 @@ def teardown def test_render_template Rails.stub(:root, File.expand_path(FIXTURE_LOAD_PATH)) do - ActiveSupport.event_reporter.with_debug do + with_debug_event_reporting do assert_event_reported("action_view.render_start", payload: { identifier: "test/hello_world.erb", layout: nil }) do event = assert_event_reported("action_view.render_template", payload: { identifier: "test/hello_world.erb", layout: nil }) do @view.render(template: "test/hello_world") @@ -50,7 +50,7 @@ def test_render_template def test_render_template_with_layout Rails.stub(:root, File.expand_path(FIXTURE_LOAD_PATH)) do - ActiveSupport.event_reporter.with_debug do + with_debug_event_reporting do payload = { identifier: "test/hello_world.erb", layout: "layouts/yield" } assert_event_reported("action_view.render_start", payload:) do event = assert_event_reported("action_view.render_template", payload:) do @@ -66,7 +66,7 @@ def test_render_template_with_layout def test_render_file_template Rails.stub(:root, File.expand_path(FIXTURE_LOAD_PATH)) do - ActiveSupport.event_reporter.with_debug do + with_debug_event_reporting do assert_event_reported("action_view.render_start", payload: { identifier: "test/hello_world.erb", layout: nil }) do event = assert_event_reported("action_view.render_template", payload: { identifier: "test/hello_world.erb", layout: nil }) do @view.render(file: "#{FIXTURE_LOAD_PATH}/test/hello_world.erb") @@ -81,7 +81,7 @@ def test_render_file_template def test_render_text_template Rails.stub(:root, File.expand_path(FIXTURE_LOAD_PATH)) do - ActiveSupport.event_reporter.with_debug do + with_debug_event_reporting do assert_event_reported("action_view.render_start", payload: { identifier: "text template", layout: nil }) do event = assert_event_reported("action_view.render_template", payload: { identifier: "text template", layout: nil }) do @view.render(plain: "TEXT") @@ -96,7 +96,7 @@ def test_render_text_template def test_render_inline_template Rails.stub(:root, File.expand_path(FIXTURE_LOAD_PATH)) do - ActiveSupport.event_reporter.with_debug do + with_debug_event_reporting do assert_event_reported("action_view.render_start", payload: { identifier: "inline template", layout: nil }) do event = assert_event_reported("action_view.render_template", payload: { identifier: "inline template", layout: nil }) do @view.render(inline: "<%= 'TEXT' %>") @@ -111,7 +111,7 @@ def test_render_inline_template def test_render_partial_with_implicit_path Rails.stub(:root, File.expand_path(FIXTURE_LOAD_PATH)) do - ActiveSupport.event_reporter.with_debug do + with_debug_event_reporting do payload = { identifier: "customers/_customer.html.erb", layout: nil, cache_hit: nil } event = assert_event_reported("action_view.render_partial", payload:) do @view.render(Customer.new("david"), greeting: "hi") @@ -128,7 +128,7 @@ def test_render_partial_with_cache_is_missed set_view_cache_dependencies set_cache_controller - ActiveSupport.event_reporter.with_debug do + with_debug_event_reporting do payload = { identifier: "test/_cached_customer.erb", layout: nil, cache_hit: :miss } event = assert_event_reported("action_view.render_partial", payload:) do @view.render(partial: "test/cached_customer", locals: { cached_customer: Customer.new("david") }) @@ -147,7 +147,7 @@ def test_render_partial_with_cache_is_hit @view.render(partial: "test/cached_customer", locals: { cached_customer: Customer.new("david") }) - ActiveSupport.event_reporter.with_debug do + with_debug_event_reporting do payload = { identifier: "test/_cached_customer.erb", layout: nil, cache_hit: :hit } event = assert_event_reported("action_view.render_partial", payload:) do # Second render should hit cache. @@ -165,7 +165,7 @@ def test_render_partial_as_layout set_view_cache_dependencies set_cache_controller - ActiveSupport.event_reporter.with_debug do + with_debug_event_reporting do event = assert_event_reported("action_view.render_partial", payload: { identifier: "layouts/_yield_only.erb", layout: nil }) do @view.render(layout: "layouts/yield_only") { "hello" } end @@ -181,7 +181,7 @@ def test_render_partial_with_layout set_view_cache_dependencies set_cache_controller - ActiveSupport.event_reporter.with_debug do + with_debug_event_reporting do payload = { identifier: "test/_partial.html.erb", layout: "layouts/_yield_only" } event = assert_event_reported("action_view.render_partial", payload:) do @view.render(partial: "partial", layout: "layouts/yield_only") @@ -198,7 +198,7 @@ def test_render_uncached_outer_partial_with_inner_cached_partial_wont_mix_cache_ set_view_cache_dependencies set_cache_controller - ActiveSupport.event_reporter.with_debug do + with_debug_event_reporting do event = assert_event_reported("action_view.render_partial", payload: { identifier: "test/_cached_customer.erb", layout: nil }) do @view.render(partial: "test/nested_cached_customer", locals: { cached_customer: Customer.new("Stan") }) end @@ -223,7 +223,7 @@ def test_render_cached_outer_partial_with_cached_inner_partial set_view_cache_dependencies set_cache_controller - ActiveSupport.event_reporter.with_debug do + with_debug_event_reporting do assert_event_reported("action_view.render_partial", payload: { identifier: "test/_cached_customer.erb", layout: nil }) do assert_event_reported("action_view.render_partial", payload: { identifier: "test/_cached_nested_cached_customer.erb", layout: nil, cache_hit: :miss }) do @view.render(partial: "test/cached_nested_cached_customer", locals: { cached_customer: Customer.new("Stan") }) @@ -245,7 +245,7 @@ def test_render_partial_with_cache_hit_and_missed set_view_cache_dependencies set_cache_controller - ActiveSupport.event_reporter.with_debug do + with_debug_event_reporting do assert_event_reported("action_view.render_partial", payload: { identifier: "test/_cached_customer.erb", layout: nil, cache_hit: :miss }) do @view.render(partial: "test/cached_customer", locals: { cached_customer: Customer.new("david") }) end @@ -264,7 +264,7 @@ def test_render_collection_template Rails.stub(:root, File.expand_path(FIXTURE_LOAD_PATH)) do set_cache_controller - ActiveSupport.event_reporter.with_debug do + with_debug_event_reporting do event = assert_event_reported("action_view.render_collection", payload: { identifier: "test/_customer.erb", layout: nil, cache_hits: nil, count: 2 }) do @view.render(partial: "test/customer", collection: [ Customer.new("david"), Customer.new("mary") ]) end @@ -279,7 +279,7 @@ def test_render_collection_template_with_layout Rails.stub(:root, File.expand_path(FIXTURE_LOAD_PATH)) do set_cache_controller - ActiveSupport.event_reporter.with_debug do + with_debug_event_reporting do event = assert_event_reported("action_view.render_collection", payload: { identifier: "test/_customer.erb", layout: "layouts/_yield_only", cache_hits: nil, count: 2 }) do @view.render(partial: "test/customer", layout: "layouts/yield_only", collection: [ Customer.new("david"), Customer.new("mary") ]) end @@ -294,7 +294,7 @@ def test_render_collection_with_implicit_path Rails.stub(:root, File.expand_path(FIXTURE_LOAD_PATH)) do set_cache_controller - ActiveSupport.event_reporter.with_debug do + with_debug_event_reporting do event = assert_event_reported("action_view.render_collection", payload: { identifier: "customers/_customer.html.erb", layout: nil, cache_hits: nil, count: 2 }) do @view.render([ Customer.new("david"), Customer.new("mary") ], greeting: "hi") end @@ -309,7 +309,7 @@ def test_render_collection_template_without_path Rails.stub(:root, File.expand_path(FIXTURE_LOAD_PATH)) do set_cache_controller - ActiveSupport.event_reporter.with_debug do + with_debug_event_reporting do event = assert_event_reported("action_view.render_collection", payload: { identifier: "templates", layout: nil, cache_hits: nil, count: 2 }) do @view.render([ GoodCustomer.new("david"), Customer.new("mary") ], greeting: "hi") end @@ -325,7 +325,7 @@ def test_render_collection_with_cached_set set_view_cache_dependencies set_cache_controller - ActiveSupport.event_reporter.with_debug do + with_debug_event_reporting do event = assert_event_reported("action_view.render_collection", payload: { identifier: "customers/_customer.html.erb", layout: nil, cache_hits: 0, count: 2 }) do @view.render(partial: "customers/customer", collection: [ Customer.new("david"), Customer.new("mary") ], cached: true, locals: { greeting: "hi" }) diff --git a/activerecord/test/cases/structured_event_subscriber_test.rb b/activerecord/test/cases/structured_event_subscriber_test.rb index a0e95acc11292..b219968e0c254 100644 --- a/activerecord/test/cases/structured_event_subscriber_test.rb +++ b/activerecord/test/cases/structured_event_subscriber_test.rb @@ -15,7 +15,7 @@ class StructuredEventSubscriberTest < ActiveRecord::TestCase Event = Struct.new(:duration, :payload) def run(*) - ActiveSupport.event_reporter.with_debug do + with_debug_event_reporting do super end end diff --git a/activestorage/test/structured_event_subscriber_test.rb b/activestorage/test/structured_event_subscriber_test.rb index f44b4eae9030e..e0484c1ef736b 100644 --- a/activestorage/test/structured_event_subscriber_test.rb +++ b/activestorage/test/structured_event_subscriber_test.rb @@ -64,7 +64,7 @@ class StructuredEventSubscriberTest < ActiveSupport::TestCase blob = create_blob(filename: "avatar.jpg") user = User.create!(name: "Test", avatar: blob) - ActiveSupport.event_reporter.with_debug do + with_debug_event_reporting do assert_event_reported("active_storage.service_exist", payload: { key: /.*/, exist: true }) do user.avatar.service.exist? user.avatar.key end @@ -75,7 +75,7 @@ class StructuredEventSubscriberTest < ActiveSupport::TestCase blob = create_blob(filename: "avatar.jpg") user = User.create!(name: "Test", avatar: blob) - ActiveSupport.event_reporter.with_debug do + with_debug_event_reporting do assert_event_reported("active_storage.service_url", payload: { key: /.*/, url: /.*/ }) do user.avatar.url end @@ -98,7 +98,7 @@ class StructuredEventSubscriberTest < ActiveSupport::TestCase service = ActiveStorage::Service.configure :mirror, config service.upload blob.key, StringIO.new(blob.download), checksum: blob.checksum - ActiveSupport.event_reporter.with_debug do + with_debug_event_reporting do assert_event_reported("active_storage.service_mirror", payload: { key: /.*/, url: /.*/ }) do service.mirror blob.key, checksum: blob.checksum end diff --git a/activesupport/CHANGELOG.md b/activesupport/CHANGELOG.md index 26de97f2850c5..b809328401411 100644 --- a/activesupport/CHANGELOG.md +++ b/activesupport/CHANGELOG.md @@ -1,3 +1,12 @@ +* Introduce `ActiveSupport::Testing::EventReporterAssertions#with_debug_event_reporting` + to enable event reporter debug mode in tests. + + The previous way to enable debug mode is by using `#with_debug` on the + event reporter itself, which is too verbose. This new helper will help + clear up any confusion on how to test debug events. + + *Gannon McGibbon* + * Add `ActiveSupport::StructuredEventSubscriber` for consuming notifications and emitting structured event logs. Events may be emitted with the `#emit_event` or `#emit_debug_event` methods. diff --git a/activesupport/lib/active_support/testing/event_reporter_assertions.rb b/activesupport/lib/active_support/testing/event_reporter_assertions.rb index e7792395089c7..c3eea2c370cd6 100644 --- a/activesupport/lib/active_support/testing/event_reporter_assertions.rb +++ b/activesupport/lib/active_support/testing/event_reporter_assertions.rb @@ -212,6 +212,16 @@ def assert_events_reported(expected_events, &block) assert(true) end + + # Allows debug events to be reported to +Rails.event+ for the duration of a given block. + # + # with_debug_event_reporting do + # service_that_reports_debug_events.perform + # end + # + def with_debug_event_reporting(&block) + ActiveSupport.event_reporter.with_debug(&block) + end end end end diff --git a/activesupport/test/structured_event_subscriber_test.rb b/activesupport/test/structured_event_subscriber_test.rb index a33341289398d..a56fca679ac71 100644 --- a/activesupport/test/structured_event_subscriber_test.rb +++ b/activesupport/test/structured_event_subscriber_test.rb @@ -39,7 +39,7 @@ def test_emit_event_calls_event_reporter_notify end def test_emit_debug_event_calls_event_reporter_debug - ActiveSupport.event_reporter.with_debug do + with_debug_event_reporting do assert_event_reported("test.debug", payload: { debug: "info" }) do @subscriber.emit_debug_event("test.debug", { debug: "info" }) end @@ -89,7 +89,7 @@ def test_debug_only_methods end assert_error_reported(TestSubscriber::DebugOnlyError) do - ActiveSupport.event_reporter.with_debug do + with_debug_event_reporting do ActiveSupport::Notifications.instrument("debug_only_event.test") end end From 2831cd0a0aa2a1aba663a27e700deb7af20b807d Mon Sep 17 00:00:00 2001 From: Gannon McGibbon Date: Thu, 11 Sep 2025 17:51:27 -0500 Subject: [PATCH 0684/1075] Make engine routes filterable in bin/rails routes, improve engine formatting Allow engine routes to be filterable in the routing inspector, and improve formatting of engine routing output. Co-authored-by: Dennis Paagman --- actionpack/CHANGELOG.md | 26 ++++ .../lib/action_dispatch/routing/inspector.rb | 138 ++++++++++-------- .../test/dispatch/routing/inspector_test.rb | 63 +++++++- 3 files changed, 164 insertions(+), 63 deletions(-) diff --git a/actionpack/CHANGELOG.md b/actionpack/CHANGELOG.md index d67e6c75994c5..c4c37e823c0ab 100644 --- a/actionpack/CHANGELOG.md +++ b/actionpack/CHANGELOG.md @@ -1,3 +1,29 @@ +* Add engine route filtering and better formatting in `bin/rails routes`. + + Allow engine routes to be filterable in the routing inspector, and + improve formatting of engine routing output. + + Before: + ``` + > bin/rails routes -e engine_only + No routes were found for this grep pattern. + For more information about routes, see the Rails guide: https://guides.rubyonrails.org/routing.html. + ``` + + After: + ``` + > bin/rails routes -e engine_only + Routes for application: + No routes were found for this grep pattern. + For more information about routes, see the Rails guide: https://guides.rubyonrails.org/routing.html. + + Routes for Test::Engine: + Prefix Verb URI Pattern Controller#Action + engine GET /engine_only(.:format) a#b + ``` + + *Dennis Paagman*, *Gannon McGibbon* + * Add structured events for Action Pack and Action Dispatch: - `action_dispatch.redirect` - `action_controller.request_started` diff --git a/actionpack/lib/action_dispatch/routing/inspector.rb b/actionpack/lib/action_dispatch/routing/inspector.rb index b72c593af3cbc..e5e25dbeec648 100644 --- a/actionpack/lib/action_dispatch/routing/inspector.rb +++ b/actionpack/lib/action_dispatch/routing/inspector.rb @@ -64,6 +64,14 @@ def internal? def engine? app.engine? end + + def to_h + { name: name, + verb: verb, + path: path, + reqs: reqs, + source_location: source_location } + end end ## @@ -72,33 +80,51 @@ def engine? # not use this class. class RoutesInspector # :nodoc: def initialize(routes) - @engines = {} - @routes = routes + @routes = wrap_routes(routes) + @engines = load_engines_routes end def format(formatter, filter = {}) - routes_to_display = filter_routes(normalize_filter(filter)) - routes = collect_routes(routes_to_display) - if routes.none? - formatter.no_routes(collect_routes(@routes), filter) - return formatter.result - end + all_routes = { nil => @routes }.merge(@engines) - formatter.header routes - formatter.section routes - - @engines.each do |name, engine_routes| - formatter.section_title "Routes for #{name}" - if engine_routes.any? - formatter.header engine_routes - formatter.section engine_routes - end + all_routes.each do |engine_name, routes| + format_routes(formatter, filter, engine_name, routes) end formatter.result end private + def format_routes(formatter, filter, engine_name, routes) + routes = filter_routes(routes, normalize_filter(filter)).map(&:to_h) + + formatter.section_title "Routes for #{engine_name || "application"}" if @engines.any? + if routes.any? + formatter.header routes + formatter.section routes + else + formatter.no_routes engine_name, routes, filter + end + formatter.footer routes + end + + def wrap_routes(routes) + routes.routes.map { |route| RouteWrapper.new(route) }.reject(&:internal?) + end + + def load_engines_routes + engine_routes = @routes.select(&:engine?) + + engines = engine_routes.to_h do |engine_route| + engine_app_routes = engine_route.rack_app.routes + engine_app_routes = engine_app_routes.routes if engine_app_routes.is_a?(ActionDispatch::Routing::RouteSet) + + [engine_route.endpoint, wrap_routes(engine_app_routes)] + end + + engines + end + def normalize_filter(filter) if filter[:controller] { controller: /#{filter[:controller].underscore.sub(/_?controller\z/, "")}/ } @@ -118,39 +144,13 @@ def normalize_filter(filter) end end - def filter_routes(filter) + def filter_routes(routes, filter) if filter - @routes.select do |route| - route_wrapper = RouteWrapper.new(route) - filter.any? { |filter_type, value| route_wrapper.matches_filter?(filter_type, value) } + routes.select do |route| + filter.any? { |filter_type, value| route.matches_filter?(filter_type, value) } end else - @routes - end - end - - def collect_routes(routes) - routes.collect do |route| - RouteWrapper.new(route) - end.reject(&:internal?).collect do |route| - collect_engine_routes(route) - - { name: route.name, - verb: route.verb, - path: route.path, - reqs: route.reqs, - source_location: route.source_location } - end - end - - def collect_engine_routes(route) - name = route.endpoint - return unless route.engine? - return if @engines[name] - - routes = route.rack_app.routes - if routes.is_a?(ActionDispatch::Routing::RouteSet) - @engines[name] = collect_routes(routes.routes) + routes end end end @@ -174,27 +174,36 @@ def section(routes) def header(routes) end - def no_routes(routes, filter) - @buffer << - if routes.none? - <<~MESSAGE - You don't have any routes defined! + def footer(routes) + end - Please add some routes in config/routes.rb. - MESSAGE - elsif filter.key?(:controller) + def no_routes(engine, routes, filter) + @buffer << + if filter.key?(:controller) "No routes were found for this controller." elsif filter.key?(:grep) "No routes were found for this grep pattern." + elsif routes.none? + if engine + "No routes defined." + else + <<~MESSAGE + You don't have any routes defined! + + Please add some routes in config/routes.rb. + MESSAGE + end end - @buffer << "For more information about routes, see the Rails guide: https://guides.rubyonrails.org/routing.html." + unless engine + @buffer << "For more information about routes, see the Rails guide: https://guides.rubyonrails.org/routing.html." + end end end class Sheet < Base def section_title(title) - @buffer << "\n#{title}:" + @buffer << "#{title}:" end def section(routes) @@ -205,6 +214,10 @@ def header(routes) @buffer << draw_header(routes) end + def footer(routes) + @buffer << "" + end + private def draw_section(routes) header_lengths = ["Prefix", "Verb", "URI Pattern"].map(&:length) @@ -235,13 +248,17 @@ def initialize(width: IO.console_size[1]) end def section_title(title) - @buffer << "\n#{"[ #{title} ]"}" + @buffer << "#{"[ #{title} ]"}" end def section(routes) @buffer << draw_expanded_section(routes) end + def footer(routes) + @buffer << "" + end + private def draw_expanded_section(routes) routes.map.each_with_index do |r, i| @@ -272,7 +289,7 @@ def header(routes) super end - def no_routes(routes, filter) + def no_routes(engine, routes, filter) @buffer << if filter.none? "No unused routes found." @@ -303,6 +320,9 @@ def section(routes) def header(routes) end + def footer(routes) + end + def no_routes(*) @buffer << <<~MESSAGE

You don't have any routes defined!

diff --git a/actionpack/test/dispatch/routing/inspector_test.rb b/actionpack/test/dispatch/routing/inspector_test.rb index 80049c27ac235..5cf4ed6ec7ddd 100644 --- a/actionpack/test/dispatch/routing/inspector_test.rb +++ b/actionpack/test/dispatch/routing/inspector_test.rb @@ -36,6 +36,7 @@ def self.inspect end assert_equal [ + "Routes for application:", " Prefix Verb URI Pattern Controller#Action", "custom_assets GET /custom/assets(.:format) custom_assets#show", " blog /blog Blog::Engine", @@ -60,10 +61,12 @@ def self.inspect end assert_equal [ + "Routes for application:", "Prefix Verb URI Pattern Controller#Action", " blog /blog Blog::Engine", "", - "Routes for Blog::Engine:" + "Routes for Blog::Engine:", + "No routes defined.", ], output end @@ -336,7 +339,8 @@ def self.inspect mount engine => "/blog", :as => "blog" end - expected = ["--[ Route 1 ]----------", + expected = [ "[ Routes for application ]", + "--[ Route 1 ]----------", "Prefix | custom_assets", "Verb | GET", "URI | /custom/assets(.:format)", @@ -380,7 +384,7 @@ def test_no_routes_matched_filter_when_expanded end def test_not_routes_when_expanded - output = draw(grep: "rails/dummy", formatter: ActionDispatch::Routing::ConsoleFormatter::Expanded.new) { } + output = draw(formatter: ActionDispatch::Routing::ConsoleFormatter::Expanded.new) { } assert_equal [ "You don't have any routes defined!", @@ -444,7 +448,7 @@ def test_no_routes_matched_filter end def test_no_routes_were_defined - output = draw(grep: "Rails::DummyController") { } + output = draw { } assert_equal [ "You don't have any routes defined!", @@ -488,6 +492,57 @@ def test_route_with_proc_handler ], output end + def test_displaying_routes_for_engines_with_filter + engine = Class.new(Rails::Engine) do + def self.inspect + "Blog::Engine" + end + end + engine.routes.draw do + get "/cart", to: "cart#show" + end + + output = draw(grep: "cart") do + get "/custom/assets", to: "custom_assets#show" + mount engine => "/blog", :as => "blog" + end + + assert_equal [ + "Routes for application:", + "No routes were found for this grep pattern.", + "For more information about routes, see the Rails guide: https://guides.rubyonrails.org/routing.html.", + "", + "Routes for Blog::Engine:", + "Prefix Verb URI Pattern Controller#Action", + " cart GET /cart(.:format) cart#show" + ], output + end + + def test_displaying_routes_for_engines_with_filter_not_matched + engine = Class.new(Rails::Engine) do + def self.inspect + "Blog::Engine" + end + end + engine.routes.draw do + get "/cart", to: "cart#show" + end + + output = draw(grep: "dummy") do + get "/custom/assets", to: "custom_assets#show" + mount engine => "/blog", :as => "blog" + end + + assert_equal [ + "Routes for application:", + "No routes were found for this grep pattern.", + "For more information about routes, see the Rails guide: https://guides.rubyonrails.org/routing.html.", + "", + "Routes for Blog::Engine:", + "No routes were found for this grep pattern.", + ], output + end + private def draw(formatter: ActionDispatch::Routing::ConsoleFormatter::Sheet.new, **options, &block) @set.draw(&block) From 51d4feaabc88267743bf0285f1392e4523f03d96 Mon Sep 17 00:00:00 2001 From: Matthew Draper Date: Wed, 24 Sep 2025 01:09:12 +0930 Subject: [PATCH 0685/1075] Remove loading behaviour from interlock The running/unloading interaction is still needed, but "loading" protection was only required for classic autoloading. --- .../lib/action_controller/metal/live.rb | 11 +- .../abstract/connection_pool/queue.rb | 4 +- .../abstract/database_statements.rb | 4 +- .../connection_adapters/abstract_adapter.rb | 5 +- .../test/cases/connection_pool_test.rb | 6 +- .../load_interlock_aware_monitor.rb | 70 ++-------- .../concurrency/thread_monitor.rb | 55 ++++++++ .../lib/active_support/dependencies.rb | 7 +- .../active_support/dependencies/interlock.rb | 16 ++- .../load_interlock_aware_monitor_test.rb | 75 ++--------- .../test/concurrency/thread_monitor_test.rb | 105 +++++++++++++++ guides/source/threading_and_code_execution.md | 122 ++---------------- 12 files changed, 223 insertions(+), 257 deletions(-) create mode 100644 activesupport/lib/active_support/concurrency/thread_monitor.rb create mode 100644 activesupport/test/concurrency/thread_monitor_test.rb diff --git a/actionpack/lib/action_controller/metal/live.rb b/actionpack/lib/action_controller/metal/live.rb index 2f6013cfc14e5..a449f522c7957 100644 --- a/actionpack/lib/action_controller/metal/live.rb +++ b/actionpack/lib/action_controller/metal/live.rb @@ -236,12 +236,7 @@ def call_on_error private def each_chunk(&block) - loop do - str = nil - ActiveSupport::Dependencies.interlock.permit_concurrent_loads do - str = @buf.pop - end - break unless str + while str = @buf.pop yield str end end @@ -306,9 +301,7 @@ def process(name) end end - ActiveSupport::Dependencies.interlock.permit_concurrent_loads do - @_response.await_commit - end + @_response.await_commit raise error if error end diff --git a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool/queue.rb b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool/queue.rb index ef9e2156cc6d2..6b242d98629c9 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool/queue.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool/queue.rb @@ -129,9 +129,7 @@ def wait_poll(timeout) t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC) elapsed = 0 loop do - ActiveSupport::Dependencies.interlock.permit_concurrent_loads do - @cond.wait(timeout - elapsed) - end + @cond.wait(timeout - elapsed) return remove if any? 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 0b68b8a61946e..08ced7f2abb98 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb @@ -568,9 +568,7 @@ def raw_execute(sql, name = nil, binds = [], prepare: false, async: false, allow type_casted_binds = type_casted_binds(binds) log(sql, name, binds, type_casted_binds, async: async, allow_retry: allow_retry) do |notification_payload| with_raw_connection(allow_retry: allow_retry, materialize_transactions: materialize_transactions) do |conn| - result = ActiveSupport::Dependencies.interlock.permit_concurrent_loads do - perform_query(conn, sql, binds, type_casted_binds, prepare: prepare, notification_payload: notification_payload, batch: batch) - end + result = perform_query(conn, sql, binds, type_casted_binds, prepare: prepare, notification_payload: notification_payload, batch: batch) handle_warnings(result, sql) result end diff --git a/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb b/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb index 6d4626e05a298..48d63feffb498 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb @@ -5,6 +5,7 @@ require "active_record/connection_adapters/abstract/schema_creation" require "active_support/concurrency/null_lock" require "active_support/concurrency/load_interlock_aware_monitor" +require "active_support/concurrency/thread_monitor" require "arel/collectors/bind" require "arel/collectors/composite" require "arel/collectors/sql_string" @@ -190,9 +191,9 @@ def lock_thread=(lock_thread) # :nodoc: @lock = case lock_thread when Thread - ActiveSupport::Concurrency::ThreadLoadInterlockAwareMonitor.new + ActiveSupport::Concurrency::ThreadMonitor.new when Fiber - ActiveSupport::Concurrency::LoadInterlockAwareMonitor.new + ::Monitor.new else ActiveSupport::Concurrency::NullLock end diff --git a/activerecord/test/cases/connection_pool_test.rb b/activerecord/test/cases/connection_pool_test.rb index 09efacd18fb1c..9ff204432ce22 100644 --- a/activerecord/test/cases/connection_pool_test.rb +++ b/activerecord/test/cases/connection_pool_test.rb @@ -173,8 +173,10 @@ def test_full_pool_blocking_shares_load_interlock connection_latch.count_down load_interlock_latch.wait - ActiveSupport::Dependencies.interlock.loading do - able_to_load = true + assert_deprecated(/ActiveSupport::Dependencies::Interlock#loading is deprecated/, ActiveSupport.deprecator) do + ActiveSupport::Dependencies.interlock.loading do + able_to_load = true + end end end end diff --git a/activesupport/lib/active_support/concurrency/load_interlock_aware_monitor.rb b/activesupport/lib/active_support/concurrency/load_interlock_aware_monitor.rb index 84729e25f4ef0..92b31323b7cd5 100644 --- a/activesupport/lib/active_support/concurrency/load_interlock_aware_monitor.rb +++ b/activesupport/lib/active_support/concurrency/load_interlock_aware_monitor.rb @@ -4,69 +4,15 @@ module ActiveSupport module Concurrency - module LoadInterlockAwareMonitorMixin # :nodoc: - EXCEPTION_NEVER = { Exception => :never }.freeze - EXCEPTION_IMMEDIATE = { Exception => :immediate }.freeze - private_constant :EXCEPTION_NEVER, :EXCEPTION_IMMEDIATE - - # Enters an exclusive section, but allows dependency loading while blocked - def mon_enter - mon_try_enter || - ActiveSupport::Dependencies.interlock.permit_concurrent_loads { super } - end - - def synchronize(&block) - Thread.handle_interrupt(EXCEPTION_NEVER) do - mon_enter - - begin - Thread.handle_interrupt(EXCEPTION_IMMEDIATE, &block) - ensure - mon_exit - end - end - end - end # A monitor that will permit dependency loading while blocked waiting for # the lock. - class LoadInterlockAwareMonitor < Monitor - include LoadInterlockAwareMonitorMixin - end - - class ThreadLoadInterlockAwareMonitor # :nodoc: - prepend LoadInterlockAwareMonitorMixin - - def initialize - @owner = nil - @count = 0 - @mutex = Mutex.new - end - - private - def mon_try_enter - if @owner != Thread.current - return false unless @mutex.try_lock - @owner = Thread.current - end - @count += 1 - end - - def mon_enter - @mutex.lock if @owner != Thread.current - @owner = Thread.current - @count += 1 - end - - def mon_exit - unless @owner == Thread.current - raise ThreadError, "current thread not owner" - end - - @count -= 1 - return unless @count == 0 - @owner = nil - @mutex.unlock - end - end + LoadInterlockAwareMonitor = ActiveSupport::Deprecation::DeprecatedConstantProxy.new( + "ActiveSupport::Concurrency::LoadInterlockAwareMonitor", + "::Monitor", + ActiveSupport.deprecator, + message: "ActiveSupport::Concurrency::LoadInterlockAwareMonitor is deprecated and will be " \ + "removed in Rails 9.0. Use Monitor directly instead, as the loading interlock is " \ + "no longer used." + ) end end diff --git a/activesupport/lib/active_support/concurrency/thread_monitor.rb b/activesupport/lib/active_support/concurrency/thread_monitor.rb new file mode 100644 index 0000000000000..d28c8893c75a1 --- /dev/null +++ b/activesupport/lib/active_support/concurrency/thread_monitor.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module ActiveSupport + module Concurrency + class ThreadMonitor # :nodoc: + EXCEPTION_NEVER = { Exception => :never }.freeze + EXCEPTION_IMMEDIATE = { Exception => :immediate }.freeze + private_constant :EXCEPTION_NEVER, :EXCEPTION_IMMEDIATE + + def initialize + @owner = nil + @count = 0 + @mutex = Mutex.new + end + + def synchronize(&block) + Thread.handle_interrupt(EXCEPTION_NEVER) do + mon_enter + + begin + Thread.handle_interrupt(EXCEPTION_IMMEDIATE, &block) + ensure + mon_exit + end + end + end + + private + def mon_try_enter + if @owner != Thread.current + return false unless @mutex.try_lock + @owner = Thread.current + end + @count += 1 + end + + def mon_enter + @mutex.lock if @owner != Thread.current + @owner = Thread.current + @count += 1 + end + + def mon_exit + unless @owner == Thread.current + raise ThreadError, "current thread not owner" + end + + @count -= 1 + return unless @count == 0 + @owner = nil + @mutex.unlock + end + end + end +end diff --git a/activesupport/lib/active_support/dependencies.rb b/activesupport/lib/active_support/dependencies.rb index aed692339f20f..c43cd5a1bc481 100644 --- a/activesupport/lib/active_support/dependencies.rb +++ b/activesupport/lib/active_support/dependencies.rb @@ -21,7 +21,12 @@ def self.run_interlock(&block) # preventing any other thread from being inside a #run_interlock # block at the same time. def self.load_interlock(&block) - interlock.loading(&block) + ActiveSupport.deprecator.warn( + "ActiveSupport::Dependencies.load_interlock is deprecated and " \ + "will be removed in Rails 9.0. The loading interlock is no longer " \ + "used since Rails switched to Zeitwerk for autoloading." + ) + yield if block end # Execute the supplied block while holding an exclusive lock, diff --git a/activesupport/lib/active_support/dependencies/interlock.rb b/activesupport/lib/active_support/dependencies/interlock.rb index e0e32e821c918..88b69b065e0d6 100644 --- a/activesupport/lib/active_support/dependencies/interlock.rb +++ b/activesupport/lib/active_support/dependencies/interlock.rb @@ -10,19 +10,24 @@ def initialize # :nodoc: end def loading(&block) - @lock.exclusive(purpose: :load, compatible: [:load], after_compatible: [:load], &block) + ActiveSupport.deprecator.warn( + "ActiveSupport::Dependencies::Interlock#loading is deprecated and " \ + "will be removed in Rails 9.0. The loading interlock is no longer " \ + "used since Rails switched to Zeitwerk for autoloading." + ) + yield if block end def unloading(&block) - @lock.exclusive(purpose: :unload, compatible: [:load, :unload], after_compatible: [:load, :unload], &block) + @lock.exclusive(purpose: :unload, compatible: [:unload], after_compatible: [:unload], &block) end def start_unloading - @lock.start_exclusive(purpose: :unload, compatible: [:load, :unload]) + @lock.start_exclusive(purpose: :unload, compatible: [:unload]) end def done_unloading - @lock.stop_exclusive(compatible: [:load, :unload]) + @lock.stop_exclusive(compatible: [:unload]) end def start_running @@ -38,7 +43,8 @@ def running(&block) end def permit_concurrent_loads(&block) - @lock.yield_shares(compatible: [:load], &block) + # Soft deprecated: no deprecation warning for now, but this is a no-op. + yield if block end def raw_state(&block) # :nodoc: diff --git a/activesupport/test/concurrency/load_interlock_aware_monitor_test.rb b/activesupport/test/concurrency/load_interlock_aware_monitor_test.rb index c421b66587574..1a744376f1904 100644 --- a/activesupport/test/concurrency/load_interlock_aware_monitor_test.rb +++ b/activesupport/test/concurrency/load_interlock_aware_monitor_test.rb @@ -1,76 +1,27 @@ # frozen_string_literal: true require_relative "../abstract_unit" -require "concurrent/atomic/count_down_latch" require "active_support/concurrency/load_interlock_aware_monitor" module ActiveSupport module Concurrency - module LoadInterlockAwareMonitorTests - def test_entering_with_no_blocking - assert @monitor.mon_enter - end - - def test_entering_with_blocking - load_interlock_latch = Concurrent::CountDownLatch.new - monitor_latch = Concurrent::CountDownLatch.new - - able_to_use_monitor = false - able_to_load = false - - thread_with_load_interlock = Thread.new do - ActiveSupport::Dependencies.interlock.running do - load_interlock_latch.count_down - monitor_latch.wait - - @monitor.synchronize do - able_to_use_monitor = true - end - end - end - - thread_with_monitor_lock = Thread.new do - @monitor.synchronize do - monitor_latch.count_down - load_interlock_latch.wait - - ActiveSupport::Dependencies.interlock.loading do - able_to_load = true - end - end - end - - thread_with_load_interlock.join - thread_with_monitor_lock.join - - assert able_to_use_monitor - assert able_to_load - end - end - class LoadInterlockAwareMonitorTest < ActiveSupport::TestCase - include LoadInterlockAwareMonitorTests - - def setup - @monitor = ActiveSupport::Concurrency::LoadInterlockAwareMonitor.new - end - end - - class ThreadLoadInterlockAwareMonitorTest < ActiveSupport::TestCase - include LoadInterlockAwareMonitorTests - - def setup - @monitor = ActiveSupport::Concurrency::ThreadLoadInterlockAwareMonitor.new + def test_deprecated_constant_resolves_to_monitor + monitor = nil + assert_deprecated(/ActiveSupport::Concurrency::LoadInterlockAwareMonitor is deprecated/, ActiveSupport.deprecator) do + monitor = LoadInterlockAwareMonitor.new + end + assert_instance_of ::Monitor, monitor end - def test_lock_owned_by_thread - @monitor.synchronize do - enumerator = Enumerator.new do |yielder| - @monitor.synchronize do - yielder.yield 42 - end + def test_deprecated_constant_can_synchronize + assert_deprecated(/ActiveSupport::Concurrency::LoadInterlockAwareMonitor is deprecated/, ActiveSupport.deprecator) do + monitor = LoadInterlockAwareMonitor.new + result = nil + monitor.synchronize do + result = 42 end - assert_equal 42, enumerator.next + assert_equal 42, result end end end diff --git a/activesupport/test/concurrency/thread_monitor_test.rb b/activesupport/test/concurrency/thread_monitor_test.rb new file mode 100644 index 0000000000000..ee08c83703c4f --- /dev/null +++ b/activesupport/test/concurrency/thread_monitor_test.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +require_relative "../abstract_unit" +require "concurrent/atomic/count_down_latch" +require "active_support/concurrency/thread_monitor" + +module ActiveSupport + module Concurrency + class ThreadMonitorTest < ActiveSupport::TestCase + def setup + @monitor = ThreadMonitor.new + end + + def test_synchronize_blocks_other_threads + blocked = false + ready_latch = Concurrent::CountDownLatch.new + blocked_latch = Concurrent::CountDownLatch.new + + thread1 = Thread.new do + @monitor.synchronize do + ready_latch.count_down + blocked_latch.wait + sleep 0.1 + end + end + + thread2 = Thread.new do + ready_latch.wait + @monitor.synchronize do + blocked = true + end + end + + sleep 0.05 # Give thread2 time to try to acquire the lock + assert_not blocked, "Thread should be blocked waiting for monitor" + + blocked_latch.count_down + thread1.join + thread2.join + + assert blocked, "Thread should have acquired monitor after first thread released it" + end + + def test_reentrant_locking + count = 0 + @monitor.synchronize do + count += 1 + @monitor.synchronize do + count += 1 + @monitor.synchronize do + count += 1 + end + end + end + assert_equal 3, count + end + + def test_lock_owned_by_current_thread + @monitor.synchronize do + # Test that we can create an enumerator that also tries to acquire the lock + # This should work because the same thread already owns the lock + enumerator = Enumerator.new do |yielder| + @monitor.synchronize do + yielder.yield 42 + end + end + assert_equal 42, enumerator.next + end + end + + def test_exception_handling_releases_lock + exception_raised = false + subsequent_lock_acquired = false + + begin + @monitor.synchronize do + raise StandardError, "test exception" + end + rescue StandardError + exception_raised = true + end + + assert exception_raised + + # Ensure the lock was properly released + @monitor.synchronize do + subsequent_lock_acquired = true + end + + assert subsequent_lock_acquired + end + + def test_thread_error_on_wrong_thread_unlock + @monitor.synchronize do + thread = Thread.new do + assert_raises(ThreadError) do + @monitor.send(:mon_exit) + end + end + thread.join + end + end + end + end +end diff --git a/guides/source/threading_and_code_execution.md b/guides/source/threading_and_code_execution.md index 0771ba373e857..2337a2611b668 100644 --- a/guides/source/threading_and_code_execution.md +++ b/guides/source/threading_and_code_execution.md @@ -108,10 +108,9 @@ end ### Concurrency -The Executor will put the current thread into `running` mode in the [Load -Interlock](#load-interlock). This operation will block temporarily if another -thread is currently either autoloading a constant or unloading/reloading -the application. +The Executor will put the current thread into `running` mode in the [Reloading +Interlock](#reloading-interlock). This operation will block temporarily if another +thread is currently unloading/reloading the application. Reloader -------- @@ -210,115 +209,22 @@ Reloader is only a pass-through to the Executor. The Executor always has important work to do, like database connection management. When `config.enable_reloading` is `false` and `config.eager_load` is `true` (`production` defaults), no reloading will occur, so it does not need the -Load Interlock. With the default settings in the `development` environment, the -Executor will use the Load Interlock to ensure constants are only loaded when it -is safe. +Reloading Interlock. With the default settings in the `development` environment, the +Executor will use the Reloading Interlock to ensure code reloading is performed safely. -Load Interlock --------------- +Reloading Interlock +------------------- -The Load Interlock allows autoloading and reloading to be enabled in a +The Reloading Interlock ensures that code reloading can be performed safely in a multi-threaded runtime environment. -When one thread is performing an autoload by evaluating the class definition -from the appropriate file, it is important no other thread encounters a -reference to the partially-defined constant. - -Similarly, it is only safe to perform an unload/reload when no application code -is in mid-execution: after the reload, the `User` constant, for example, may -point to a different class. Without this rule, a poorly-timed reload would mean +It is only safe to perform an unload/reload when no application code is in +mid-execution: after the reload, the `User` constant, for example, may point to +a different class. Without this rule, a poorly-timed reload would mean `User.new.class == User`, or even `User == User`, could be false. -Both of these constraints are addressed by the Load Interlock. It keeps track of -which threads are currently running application code, loading a class, or -unloading autoloaded constants. - -Only one thread may load or unload at a time, and to do either, it must wait -until no other threads are running application code. If a thread is waiting to -perform a load, it doesn't prevent other threads from loading (in fact, they'll -cooperate, and each perform their queued load in turn, before all resuming -running together). - -### `permit_concurrent_loads` - -The Executor automatically acquires a `running` lock for the duration of its -block, and autoload knows when to upgrade to a `load` lock, and switch back to -`running` again afterwards. - -Other blocking operations performed inside the Executor block (which includes -all application code), however, can needlessly retain the `running` lock. If -another thread encounters a constant it must autoload, this can cause a -deadlock. - -For example, assuming `User` is not yet loaded, the following will deadlock: - -```ruby -Rails.application.executor.wrap do - th = Thread.new do - Rails.application.executor.wrap do - User # inner thread waits here; it cannot load - # User while another thread is running - end - end - - th.join # outer thread waits here, holding 'running' lock -end -``` - -To prevent this deadlock, the outer thread can `permit_concurrent_loads`. By -calling this method, the thread guarantees it will not dereference any -possibly-autoloaded constant inside the supplied block. The safest way to meet -that promise is to put it as close as possible to the blocking call: - -```ruby -Rails.application.executor.wrap do - th = Thread.new do - Rails.application.executor.wrap do - User # inner thread can acquire the 'load' lock, - # load User, and continue - end - end - - ActiveSupport::Dependencies.interlock.permit_concurrent_loads do - th.join # outer thread waits here, but has no lock - end -end -``` - -Another example, using Concurrent Ruby: - -```ruby -Rails.application.executor.wrap do - futures = 3.times.collect do |i| - Concurrent::Promises.future do - Rails.application.executor.wrap do - # do work here - end - end - end - - values = ActiveSupport::Dependencies.interlock.permit_concurrent_loads do - futures.collect(&:value) - end -end -``` - -### ActionDispatch::DebugLocks - -If your application is deadlocking and you think the Load Interlock may be -involved, you can temporarily add the ActionDispatch::DebugLocks middleware to -`config/application.rb`: - -```ruby -config.middleware.insert_before Rack::Sendfile, - ActionDispatch::DebugLocks -``` +The Reloading Interlock addresses this constraint by keeping track of which +threads are currently running application code, and ensuring that reloading +waits until no other threads are executing application code. -If you then restart the application and re-trigger the deadlock condition, -`/rails/locks` will show a summary of all threads currently known to the -interlock, which lock level they are holding or awaiting, and their current -backtrace. -Generally a deadlock will be caused by the interlock conflicting with some other -external lock or blocking I/O call. Once you find it, you can wrap it with -`permit_concurrent_loads`. From 45dff95effecbe85b4435ede5ee90ad21643abab Mon Sep 17 00:00:00 2001 From: eileencodes Date: Tue, 23 Sep 2025 15:41:38 -0400 Subject: [PATCH 0686/1075] Don't generate system tests by default System tests are a nice to have but don't make sense to be generated by default since you don't want to test every endpoint this way. They should be used for testing critical paths, especially where JS is involved. --- guides/source/command_line.md | 3 +- guides/source/testing.md | 50 +++++++++++++++--- railties/CHANGELOG.md | 6 +++ .../test_unit/scaffold/scaffold_generator.rb | 6 ++- railties/test/application/generators_test.rb | 1 - .../scaffold_controller_generator_test.rb | 3 +- .../generators/scaffold_generator_test.rb | 52 +++++++++---------- 7 files changed, 83 insertions(+), 38 deletions(-) diff --git a/guides/source/command_line.md b/guides/source/command_line.md index b4ee6fb96921d..327b0a0e74b69 100644 --- a/guides/source/command_line.md +++ b/guides/source/command_line.md @@ -337,6 +337,8 @@ NOTE: For a list of available field types for the `type` parameter, refer to the But instead of generating a model directly (which we'll be doing later), let's set up a scaffold. A **scaffold** in Rails is a full set of model, database migration for that model, controller to manipulate it, views to view and manipulate the data, and a test suite for each of the above. +NOTE: Starting with Rails 8.1, scaffolds no longer generate system tests by default. System tests should be reserved for critical user paths due to their slower execution and higher maintenance cost. To include system tests when scaffolding, use the `--system-tests=true` option. + We will set up a simple resource called "HighScore" that will keep track of our highest score on video games we play. ```bash @@ -360,7 +362,6 @@ $ bin/rails generate scaffold HighScore game:string score:integer create app/views/high_scores/_form.html.erb invoke test_unit create test/controllers/high_scores_controller_test.rb - create test/system/high_scores_test.rb invoke helper create app/helpers/high_scores_helper.rb invoke test_unit diff --git a/guides/source/testing.md b/guides/source/testing.md index fed3eb342abda..d3c98d6e009ee 100644 --- a/guides/source/testing.md +++ b/guides/source/testing.md @@ -40,7 +40,7 @@ then you will see: ```bash $ ls -F test -application_system_test_case.rb controllers/ helpers/ mailers/ system/ fixtures/ integration/ models/ test_helper.rb +controllers/ helpers/ mailers/ fixtures/ integration/ models/ test_helper.rb ``` ### Test Directories @@ -874,8 +874,7 @@ Functional Testing for Controllers When writing functional tests, you are focusing on testing how controller actions handle the requests and the expected result or response. Functional -controller tests are sometimes used in cases where system tests are not -appropriate, e.g., to confirm an API response. +controller tests are used to test controllers and other behavior, like API responses. ### What to Include in Your Functional Tests @@ -1419,6 +1418,45 @@ does this by running tests in either a real or a headless browser (a browser which runs in the background without opening a visible window). System tests use [Capybara](https://www.rubydoc.info/github/jnicklas/capybara) under the hood. +### When to Use System Tests + +System tests provide the most realistic testing experience as they test your +application from a user's perspective. However, they come with important +trade-offs: + +* **They are significantly slower** than unit and integration tests +* **They can be brittle** and prone to failures from timing issues or UI changes +* **They require more maintenance** as your UI evolves + +Given these trade-offs, **system tests should be reserved for critical user +paths** rather than being created for every feature. Consider writing system +tests for: + +* **Core business workflows** (e.g., user registration, checkout process, + payment flows) +* **Critical user interactions** that integrate multiple components +* **Complex JavaScript interactions** that can't be tested at lower levels + +For most features, integration tests provide a better balance of coverage and +maintainability. Save system tests for scenarios where you need to verify the +complete user experience. + +### Generating System Tests + +Rails no longer generates system tests by default when using scaffolds. This +change reflects the best practice of using system tests sparingly. You can +generate system tests in two ways: + +1. **When scaffolding**, explicitly enable system tests: + ```bash + $ bin/rails generate scaffold Article title:string body:text --system-tests=true + ``` + +2. **Generate system tests independently** for critical features: + ```bash + $ bin/rails generate system_test articles + ``` + Rails system tests are stored in the `test/system` directory in your application. To generate a system test skeleton, run the following command: @@ -1552,9 +1590,9 @@ settings. This section will demonstrate how to add a system test to your application, which tests a visit to the index page to create a new blog article. -If you used the scaffold generator, a system test skeleton was automatically -created for you. If you didn't use the scaffold generator, start by creating a -system test skeleton. +NOTE: The scaffold generator no longer creates system tests by default. To +include system tests when scaffolding, use the `--system-tests=true` option. +Otherwise, create system tests manually for your critical user paths. ```bash $ bin/rails generate system_test articles diff --git a/railties/CHANGELOG.md b/railties/CHANGELOG.md index 760d39e99b783..36a349b082e62 100644 --- a/railties/CHANGELOG.md +++ b/railties/CHANGELOG.md @@ -1,3 +1,9 @@ +* Don't generate system tests by default. + + Rails scaffold generator will no longer generate system tests by default. To enable this pass `--system-tests=true` or generate them with `bin/rails generate system_test name_of_test`. + + *Eileen M. Uchitelle* + * Optionally skip bundler-audit. Skips adding the `bin/bundler-audit` & `config/bundler-audit.yml` if the gem is not installed when `bin/rails app:update` runs. diff --git a/railties/lib/rails/generators/test_unit/scaffold/scaffold_generator.rb b/railties/lib/rails/generators/test_unit/scaffold/scaffold_generator.rb index d164c86f788db..b01eb1884f225 100644 --- a/railties/lib/rails/generators/test_unit/scaffold/scaffold_generator.rb +++ b/railties/lib/rails/generators/test_unit/scaffold/scaffold_generator.rb @@ -14,7 +14,7 @@ class ScaffoldGenerator < Base # :nodoc: desc: "Generate API functional tests" class_option :system_tests, type: :string, - desc: "Skip system test files" + desc: "Generate system test files (set to 'true' to enable)" argument :attributes, type: :array, default: [], banner: "field:type field:type" @@ -23,7 +23,9 @@ def create_test_files template template_file, File.join("test/controllers", controller_class_path, "#{controller_file_name}_controller_test.rb") - if !options.api? && options[:system_tests] + # Generate system tests if this isn't an API only app and the system + # tests option is true + if !options.api? && options[:system_tests] == "true" template "system_test.rb", File.join("test/system", class_path, "#{file_name.pluralize}_test.rb") end end diff --git a/railties/test/application/generators_test.rb b/railties/test/application/generators_test.rb index 41252dec1be04..66bb30f0af439 100644 --- a/railties/test/application/generators_test.rb +++ b/railties/test/application/generators_test.rb @@ -251,7 +251,6 @@ def check_expected app/views/posts/_form.html.erb app/views/posts/_post.html.erb test/controllers/posts_controller_test.rb - test/system/posts_test.rb app/helpers/posts_helper.rb ) assert_equal expected, files diff --git a/railties/test/generators/scaffold_controller_generator_test.rb b/railties/test/generators/scaffold_controller_generator_test.rb index 0bade332051c1..b504215456143 100644 --- a/railties/test/generators/scaffold_controller_generator_test.rb +++ b/railties/test/generators/scaffold_controller_generator_test.rb @@ -272,7 +272,8 @@ def test_model_name_option assert_no_match %r/\b(new_|edit_)?users?_(path|url)/, content end - assert_file "test/system/users_test.rb" + # System tests should not be generated by default + assert_no_file "test/system/users_test.rb" end def test_controller_tests_pass_by_default_inside_mountable_engine diff --git a/railties/test/generators/scaffold_generator_test.rb b/railties/test/generators/scaffold_generator_test.rb index 9676e80956f90..73643c7e2695e 100644 --- a/railties/test/generators/scaffold_generator_test.rb +++ b/railties/test/generators/scaffold_generator_test.rb @@ -68,14 +68,8 @@ def test_scaffold_on_invoke assert_match(/patch product_line_url\(@product_line\), params: \{ product_line: \{ approved: @product_line\.approved, product_id: @product_line\.product_id, title: @product_line\.title, user_id: @product_line\.user_id \} \}/, test) end - # System tests - assert_file "test/system/product_lines_test.rb" do |test| - assert_match(/class ProductLinesTest < ApplicationSystemTestCase/, test) - assert_match(/visit product_lines_url/, test) - assert_match(/fill_in "Title", with: @product_line\.title/, test) - assert_match(/check "Approved" if @product_line\.approved/, test) - assert_match(/assert_text "Product line was successfully updated"/, test) - end + # System tests should not be generated by default + assert_no_file "test/system/product_lines_test.rb" # Views assert_no_file "app/views/layouts/product_lines.html.erb" @@ -97,6 +91,18 @@ def test_scaffold_on_invoke assert_file "app/helpers/product_lines_helper.rb" end + def test_scaffold_with_system_tests_option + run_generator %w(product_line title:string --system-tests=true) + + # System tests should be generated when explicitly requested + assert_file "test/system/product_lines_test.rb" do |test| + assert_match(/class ProductLinesTest < ApplicationSystemTestCase/, test) + assert_match(/visit product_lines_url/, test) + assert_match(/fill_in "Title", with: @product_line\.title/, test) + assert_match(/assert_text "Product line was successfully created"/, test) + end + end + def test_api_scaffold_on_invoke run_generator %w(product_line title:string product:belongs_to user:references --api --no-template-engine --no-helper --no-assets) @@ -179,11 +185,8 @@ def test_functional_tests_without_attributes def test_system_tests_without_attributes run_generator ["product_line"] - assert_file "test/system/product_lines_test.rb" do |content| - assert_match(/class ProductLinesTest < ApplicationSystemTestCase/, content) - assert_match(/test "visiting the index"/, content) - assert_no_match(/fill_in/, content) - end + # System tests should not be generated by default + assert_no_file "test/system/product_lines_test.rb" end def test_scaffold_on_revoke @@ -268,8 +271,8 @@ def test_scaffold_with_namespace_on_invoke assert_file "test/controllers/admin/roles_controller_test.rb", /class Admin::RolesControllerTest < ActionDispatch::IntegrationTest/ - assert_file "test/system/admin/roles_test.rb", - /class Admin::RolesTest < ApplicationSystemTestCase/ + # System tests should not be generated by default + assert_no_file "test/system/admin/roles_test.rb" # Views assert_file "app/views/admin/roles/index.html.erb" do |content| @@ -476,10 +479,8 @@ def test_scaffold_generator_attachments assert_match(/^\W{6}
<%= link_to photo\.filename, photo %>/, content) end - assert_file "test/system/messages_test.rb" do |content| - assert_no_match(/fill_in "Video"/, content) - assert_no_match(/fill_in "Photos"/, content) - end + # System tests should not be generated by default + assert_no_file "test/system/messages_test.rb" end def test_scaffold_generator_rich_text @@ -497,9 +498,8 @@ def test_scaffold_generator_rich_text assert_match(/^\W{4}<%= form\.rich_textarea :content %>/, content) end - assert_file "test/system/messages_test.rb" do |content| - assert_no_match(/fill_in "Content"/, content) - end + # System tests should not be generated by default + assert_no_file "test/system/messages_test.rb" end def test_scaffold_generator_multi_db_abstract_class @@ -559,10 +559,8 @@ def test_scaffold_generator_password_digest assert_match(/password_confirmation: "secret"/, content) end - assert_file "test/system/users_test.rb" do |content| - assert_match(/fill_in "Password", with: "secret"/, content) - assert_match(/fill_in "Password confirmation", with: "secret"/, content) - end + # System tests should not be generated by default + assert_no_file "test/system/users_test.rb" assert_file "test/fixtures/users.yml" do |content| assert_match(/password_digest: (.+)$/, content) @@ -651,7 +649,7 @@ def test_scaffold_on_invoke_inside_mountable_engine assert File.exist?("app/controllers/bukkits/users_controller.rb") assert File.exist?("test/controllers/bukkits/users_controller_test.rb") - assert File.exist?("test/system/bukkits/users_test.rb") + assert_not File.exist?("test/system/bukkits/users_test.rb") assert File.exist?("app/views/bukkits/users/index.html.erb") assert File.exist?("app/views/bukkits/users/edit.html.erb") From 1e776998ed4ac8a415c1173ad55157f14368d939 Mon Sep 17 00:00:00 2001 From: Gannon McGibbon Date: Wed, 24 Sep 2025 11:57:13 -0500 Subject: [PATCH 0687/1075] Make all framework log subscribers API private Adds nodoc to log subscribers so that they may be changed more easily without breaking public API. --- actionmailer/lib/action_mailer/log_subscriber.rb | 6 +----- actionpack/lib/action_controller/log_subscriber.rb | 4 +--- actionpack/lib/action_dispatch/log_subscriber.rb | 4 +--- actionview/lib/action_view/log_subscriber.rb | 5 +---- activerecord/lib/active_record/log_subscriber.rb | 2 +- activestorage/lib/active_storage/log_subscriber.rb | 2 +- 6 files changed, 6 insertions(+), 17 deletions(-) diff --git a/actionmailer/lib/action_mailer/log_subscriber.rb b/actionmailer/lib/action_mailer/log_subscriber.rb index 130d5d83e62df..c3d36ea8a32fe 100644 --- a/actionmailer/lib/action_mailer/log_subscriber.rb +++ b/actionmailer/lib/action_mailer/log_subscriber.rb @@ -3,11 +3,7 @@ require "active_support/log_subscriber" module ActionMailer - # = Action Mailer \LogSubscriber - # - # Implements the ActiveSupport::LogSubscriber for logging notifications when - # email is delivered or received. - class LogSubscriber < ActiveSupport::LogSubscriber + class LogSubscriber < ActiveSupport::LogSubscriber # :nodoc: # An email was delivered. def deliver(event) info do diff --git a/actionpack/lib/action_controller/log_subscriber.rb b/actionpack/lib/action_controller/log_subscriber.rb index b8d7a22c8bf82..8ade5817f5d6c 100644 --- a/actionpack/lib/action_controller/log_subscriber.rb +++ b/actionpack/lib/action_controller/log_subscriber.rb @@ -1,9 +1,7 @@ # frozen_string_literal: true -# :markup: markdown - module ActionController - class LogSubscriber < ActiveSupport::LogSubscriber + class LogSubscriber < ActiveSupport::LogSubscriber # :nodoc: INTERNAL_PARAMS = %w(controller action format _method only_path) def start_processing(event) diff --git a/actionpack/lib/action_dispatch/log_subscriber.rb b/actionpack/lib/action_dispatch/log_subscriber.rb index c9d5d4ba7fb06..933f6bff8f9cf 100644 --- a/actionpack/lib/action_dispatch/log_subscriber.rb +++ b/actionpack/lib/action_dispatch/log_subscriber.rb @@ -1,9 +1,7 @@ # frozen_string_literal: true -# :markup: markdown - module ActionDispatch - class LogSubscriber < ActiveSupport::LogSubscriber + class LogSubscriber < ActiveSupport::LogSubscriber # :nodoc: def redirect(event) payload = event.payload diff --git a/actionview/lib/action_view/log_subscriber.rb b/actionview/lib/action_view/log_subscriber.rb index 3582aee4e1e3a..d79ec0fff0cd9 100644 --- a/actionview/lib/action_view/log_subscriber.rb +++ b/actionview/lib/action_view/log_subscriber.rb @@ -3,10 +3,7 @@ require "active_support/log_subscriber" module ActionView - # = Action View Log Subscriber - # - # Provides functionality so that \Rails can output logs from Action View. - class LogSubscriber < ActiveSupport::LogSubscriber + class LogSubscriber < ActiveSupport::LogSubscriber # :nodoc: VIEWS_PATTERN = /^app\/views\// def initialize diff --git a/activerecord/lib/active_record/log_subscriber.rb b/activerecord/lib/active_record/log_subscriber.rb index a76dde1ba4460..8f8799e11a57f 100644 --- a/activerecord/lib/active_record/log_subscriber.rb +++ b/activerecord/lib/active_record/log_subscriber.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module ActiveRecord - class LogSubscriber < ActiveSupport::LogSubscriber + class LogSubscriber < ActiveSupport::LogSubscriber # :nodoc: IGNORE_PAYLOAD_NAMES = ["SCHEMA", "EXPLAIN"] class_attribute :backtrace_cleaner, default: ActiveSupport::BacktraceCleaner.new diff --git a/activestorage/lib/active_storage/log_subscriber.rb b/activestorage/lib/active_storage/log_subscriber.rb index 376173c8b969d..fecdbf1e7b203 100644 --- a/activestorage/lib/active_storage/log_subscriber.rb +++ b/activestorage/lib/active_storage/log_subscriber.rb @@ -3,7 +3,7 @@ require "active_support/log_subscriber" module ActiveStorage - class LogSubscriber < ActiveSupport::LogSubscriber + class LogSubscriber < ActiveSupport::LogSubscriber # :nodoc: def service_upload(event) message = "Uploaded file to key: #{key_in(event)}" message += " (checksum: #{event.payload[:checksum]})" if event.payload[:checksum] From 732c2d06fa804b4b156d0f8a7637f336ab7de3a4 Mon Sep 17 00:00:00 2001 From: Hartley McGuire Date: Wed, 24 Sep 2025 13:19:40 -0400 Subject: [PATCH 0688/1075] Followup #55743 [ci skip] --- guides/source/testing.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/guides/source/testing.md b/guides/source/testing.md index d3c98d6e009ee..f723240698bf7 100644 --- a/guides/source/testing.md +++ b/guides/source/testing.md @@ -1448,11 +1448,13 @@ change reflects the best practice of using system tests sparingly. You can generate system tests in two ways: 1. **When scaffolding**, explicitly enable system tests: + ```bash $ bin/rails generate scaffold Article title:string body:text --system-tests=true ``` 2. **Generate system tests independently** for critical features: + ```bash $ bin/rails generate system_test articles ``` From 83e1bd0a5cc42a339a039e30ebfd3fc6274b0597 Mon Sep 17 00:00:00 2001 From: Mikey Gough Date: Fri, 19 Sep 2025 10:38:39 -0700 Subject: [PATCH 0689/1075] Dedupe schema dumps and refactor test helpers Co-authored-by: Hartley McGuire --- activerecord/CHANGELOG.md | 9 +++ .../lib/active_record/tasks/database_tasks.rb | 38 +++++++----- .../test/cases/tasks/database_tasks_test.rb | 62 +++++++++++++++++-- 3 files changed, 91 insertions(+), 18 deletions(-) diff --git a/activerecord/CHANGELOG.md b/activerecord/CHANGELOG.md index 190d62224f600..9a4e4e7d6f059 100644 --- a/activerecord/CHANGELOG.md +++ b/activerecord/CHANGELOG.md @@ -1,3 +1,12 @@ +* Optimize schema dumping to prevent duplicate file generation. + + `ActiveRecord::Tasks::DatabaseTasks.dump_all` now tracks which schema files + have already been dumped and skips dumping the same file multiple times. + This improves performance when multiple database configurations share the + same schema dump path. + + *Mikey Gough*, *Hartley McGuire* + * Add structured events for Active Record: - `active_record.strict_loading_violation` - `active_record.sql` diff --git a/activerecord/lib/active_record/tasks/database_tasks.rb b/activerecord/lib/active_record/tasks/database_tasks.rb index 03b0f786739e9..de455bdbff846 100644 --- a/activerecord/lib/active_record/tasks/database_tasks.rb +++ b/activerecord/lib/active_record/tasks/database_tasks.rb @@ -428,9 +428,15 @@ def reconstruct_from_schema(db_config, file = nil) # :nodoc: end def dump_all - with_temporary_pool_for_each do |pool| - db_config = pool.db_config + seen_schemas = [] + + ActiveRecord::Base.configurations.configs_for(env_name: ActiveRecord::Tasks::DatabaseTasks.env).each do |db_config| + schema_path = schema_dump_path(db_config, ENV["SCHEMA_FORMAT"] || db_config.schema_format) + + next if seen_schemas.include?(schema_path) + ActiveRecord::Tasks::DatabaseTasks.dump_schema(db_config, ENV["SCHEMA_FORMAT"] || db_config.schema_format) + seen_schemas << schema_path end end @@ -441,18 +447,22 @@ def dump_schema(db_config, format = db_config.schema_format) # :nodoc: filename = schema_dump_path(db_config, format) return unless filename - FileUtils.mkdir_p(db_dir) - case format.to_sym - when :ruby - File.open(filename, "w:utf-8") do |file| - ActiveRecord::SchemaDumper.dump(migration_connection_pool, file) - end - when :sql - structure_dump(db_config, filename) - if migration_connection_pool.schema_migration.table_exists? - File.open(filename, "a") do |f| - f.puts migration_connection.dump_schema_versions - f.print "\n" + with_temporary_pool(db_config) do |pool| + FileUtils.mkdir_p(db_dir) + case format.to_sym + when :ruby + File.open(filename, "w:utf-8") do |file| + ActiveRecord::SchemaDumper.dump(pool, file) + end + when :sql + structure_dump(db_config, filename) + if pool.schema_migration.table_exists? + File.open(filename, "a") do |f| + pool.with_connection do |connection| + f.puts connection.dump_schema_versions + end + f.print "\n" + end end end end diff --git a/activerecord/test/cases/tasks/database_tasks_test.rb b/activerecord/test/cases/tasks/database_tasks_test.rb index 4c4063b37eaaf..a32edaff290ad 100644 --- a/activerecord/test/cases/tasks/database_tasks_test.rb +++ b/activerecord/test/cases/tasks/database_tasks_test.rb @@ -393,6 +393,8 @@ def test_cache_dump_filename_with_path_from_the_argument_has_precedence end class DatabaseTasksDumpSchemaTest < ActiveRecord::TestCase + include DatabaseTasksHelper + def test_ensure_db_dir Dir.mktmpdir do |dir| ActiveRecord::Tasks::DatabaseTasks.stub(:db_dir, dir) do @@ -403,8 +405,10 @@ def test_ensure_db_dir FileUtils.rm_rf(dir) assert_not File.file?(path) - ActiveRecord::SchemaDumper.stub(:dump, "") do # Do not actually dump for test performances - ActiveRecord::Tasks::DatabaseTasks.dump_schema(db_config) + ActiveRecord::Base.connection_handler.stub(:establish_connection, nil) do + ActiveRecord::SchemaDumper.stub(:dump, "") do # Do not actually dump for test performances + ActiveRecord::Tasks::DatabaseTasks.dump_schema(db_config) + end end assert File.file?(path) @@ -424,8 +428,10 @@ def test_db_dir_ignored_if_included_in_schema_dump FileUtils.rm_rf(dir) assert_not File.file?(path) - ActiveRecord::SchemaDumper.stub(:dump, "") do # Do not actually dump for test performances - ActiveRecord::Tasks::DatabaseTasks.dump_schema(db_config) + ActiveRecord::Base.connection_handler.stub(:establish_connection, nil) do + ActiveRecord::SchemaDumper.stub(:dump, "") do # Do not actually dump for test performances + ActiveRecord::Tasks::DatabaseTasks.dump_schema(db_config) + end end assert File.file?(path) @@ -434,6 +440,54 @@ def test_db_dir_ignored_if_included_in_schema_dump ensure ActiveRecord::Base.clear_cache! end + + def test_dump_all_only_dumps_same_schema_once + counter = 0 + + configurations = { + "test" => { + primary: { + schema_dump: "structure.sql", + }, + secondary: { + schema_dump: "structure.sql", + } + } + } + + ActiveRecord::Tasks::DatabaseTasks.stub(:db_dir, "/db") do + with_stubbed_configurations(configurations) do + ActiveRecord::Tasks::DatabaseTasks.stub(:dump_schema, proc { counter += 1 }) do + ActiveRecord::Tasks::DatabaseTasks.dump_all + end + end + end + assert_equal 1, counter + end + + def test_dump_all_handles_path_normalization_for_deduplication + counter = 0 + + configurations = { + "test" => { + primary: { + schema_dump: "structure.sql", + }, + secondary: { + schema_dump: "db/structure.sql", + } + } + } + + ActiveRecord::Tasks::DatabaseTasks.stub(:db_dir, "db") do + with_stubbed_configurations(configurations) do + ActiveRecord::Tasks::DatabaseTasks.stub(:dump_schema, proc { counter += 1 }) do + ActiveRecord::Tasks::DatabaseTasks.dump_all + end + end + end + assert_equal 1, counter + end end class DatabaseTasksCreateAllTest < ActiveRecord::TestCase From 87051fc3ca21931e094687ee73f2a2aa954d132e Mon Sep 17 00:00:00 2001 From: Jill Klang Date: Wed, 24 Sep 2025 17:01:23 -0400 Subject: [PATCH 0690/1075] Deprecate usage of custom ActiveJob serializers The performance gains from https://github.com/rails/rails/pull/55583 meant that, when testing upgrades from 8.0 to 8.1, suddenly the app crashed and stopped evaluating due to the raised NotImplemented error in the parent class. This indexer isn't strictly required (there's a fallback), so it feels appropriate to do a proper deprecation cycle and then remove this behavior in a later version. --- activejob/lib/active_job/serializers.rb | 11 ++++++++++- activejob/test/cases/serializers_test.rb | 10 ++++++++++ guides/source/8_1_release_notes.md | 2 ++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/activejob/lib/active_job/serializers.rb b/activejob/lib/active_job/serializers.rb index 4833656b3e94a..4bd44bcb7c763 100644 --- a/activejob/lib/active_job/serializers.rb +++ b/activejob/lib/active_job/serializers.rb @@ -76,11 +76,20 @@ def index_serializers @serializers_index.clear serializers.each do |s| if s.respond_to?(:klass) - @serializers_index[s.klass] = s + begin + @serializers_index[s.klass] = s + rescue NotImplementedError => e + ActiveJob.deprecator.warn( + <<~MSG.squish + #{e.message}. This will raise an error in Rails 8.2. + MSG + ) + end elsif s.respond_to?(:klass, true) klass = s.send(:klass) ActiveJob.deprecator.warn(<<~MSG.squish) #{s.class.name}#klass method should be public. + This will raise an error in Rails 8.2. MSG @serializers_index[klass] = s end diff --git a/activejob/test/cases/serializers_test.rb b/activejob/test/cases/serializers_test.rb index 5c1615a90f291..b9bf8d4992186 100644 --- a/activejob/test/cases/serializers_test.rb +++ b/activejob/test/cases/serializers_test.rb @@ -30,6 +30,8 @@ def klass end end + class TestSerializerWithoutKlass < ActiveJob::Serializers::ObjectSerializer; end + setup do @value_object = DummyValueObject.new 123 @original_serializers = ActiveJob::Serializers.serializers @@ -93,4 +95,12 @@ def klass ActiveJob::Serializers.add_serializers DummySerializer end end + + test "raises a deprecation warning if the klass method doesn't exist" do + expected_message = "TestSerializerWithoutKlass should implement a public #klass method. This will raise an error in Rails 8.2" + + assert_deprecated(expected_message, ActiveJob.deprecator) do + ActiveJob::Serializers.add_serializers TestSerializerWithoutKlass + end + end end diff --git a/guides/source/8_1_release_notes.md b/guides/source/8_1_release_notes.md index e472b986da201..29582275f5590 100644 --- a/guides/source/8_1_release_notes.md +++ b/guides/source/8_1_release_notes.md @@ -179,6 +179,8 @@ Please refer to the [Changelog][active-job] for detailed changes. ### Deprecations +* Custom Active Job serializers must have a public `#klass` method. + ### Notable changes Action Text From 207a254cedef2c381c2898bac960b91ce14ab3a7 Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Thu, 25 Sep 2025 10:30:58 +0200 Subject: [PATCH 0691/1075] ActiveSupport::Callbacks add a fast path when there is no callbacks When initially defined, the `_run__callbacks` method is a noop. Only once a callback has been registers, we swap the implementation for the real one. This saves a little bit of performance for hooks points that are rarely used or only used in development. --- actioncable/lib/action_cable/channel/base.rb | 5 ++++- actionpack/lib/abstract_controller/base.rb | 3 ++- activesupport/lib/active_support/callbacks.rb | 21 ++++++++++++++----- 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/actioncable/lib/action_cable/channel/base.rb b/actioncable/lib/action_cable/channel/base.rb index 89c678c712e4e..906a88237fd8d 100644 --- a/actioncable/lib/action_cable/channel/base.rb +++ b/actioncable/lib/action_cable/channel/base.rb @@ -132,7 +132,10 @@ def action_methods # Except for public instance methods of Base and its ancestors ActionCable::Channel::Base.public_instance_methods(true) + # Be sure to include shadowed public instance methods of this class - public_instance_methods(false)).uniq.map(&:to_s) + public_instance_methods(false)).uniq + + methods.reject! { |m| m.start_with?("_") } + methods.map!(&:name) methods.to_set end end diff --git a/actionpack/lib/abstract_controller/base.rb b/actionpack/lib/abstract_controller/base.rb index 23d8ad8ea7397..08a7ada41de69 100644 --- a/actionpack/lib/abstract_controller/base.rb +++ b/actionpack/lib/abstract_controller/base.rb @@ -97,7 +97,8 @@ def action_methods methods = public_instance_methods(true) - internal_methods # Be sure to include shadowed public instance methods of this class. methods.concat(public_instance_methods(false)) - methods.map!(&:to_s) + methods.reject! { |m| m.start_with?("_") } + methods.map!(&:name) methods.to_set end end diff --git a/activesupport/lib/active_support/callbacks.rb b/activesupport/lib/active_support/callbacks.rb index e4309a57c7760..9e27372e05031 100644 --- a/activesupport/lib/active_support/callbacks.rb +++ b/activesupport/lib/active_support/callbacks.rb @@ -4,6 +4,7 @@ require "active_support/descendants_tracker" require "active_support/core_ext/array/extract_options" require "active_support/core_ext/class/attribute" +require "active_support/core_ext/module/redefine_method" require "active_support/core_ext/string/filters" require "active_support/core_ext/object/blank" @@ -905,12 +906,13 @@ def define_callbacks(*names) names.each do |name| name = name.to_sym - ([self] + self.descendants).each do |target| - target.set_callbacks name, CallbackChain.new(name, options) - end - module_eval <<-RUBY, __FILE__, __LINE__ + 1 - def _run_#{name}_callbacks(&block) + def _run_#{name}_callbacks + yield if block_given? + end + silence_redefinition_of_method(:_run_#{name}_callbacks) + + def _run_#{name}_callbacks!(&block) run_callbacks #{name.inspect}, &block end @@ -926,6 +928,10 @@ def _#{name}_callbacks __callbacks[#{name.inspect}] end RUBY + + ([self] + self.descendants).each do |target| + target.set_callbacks name, CallbackChain.new(name, options) + end end end @@ -941,6 +947,11 @@ def set_callbacks(name, callbacks) # :nodoc: unless singleton_class.private_method_defined?(:__class_attr__callbacks, false) self.__callbacks = __callbacks.dup end + name = name.to_sym + callbacks_was = self.__callbacks[name.to_sym] + if (callbacks_was.nil? || callbacks_was.empty?) && !callbacks.empty? + alias_method("_run_#{name}_callbacks", "_run_#{name}_callbacks!") + end self.__callbacks[name.to_sym] = callbacks self.__callbacks end From f488878f1bc0aecc33485135efc0800f1388cfdb Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Thu, 25 Sep 2025 10:37:09 +0200 Subject: [PATCH 0692/1075] Refactor ExplainRegistry to only be subscribed once used Realistically, it's only ever going to be invoked in development, hence it is wasteful to have a notification subscriber that is a noop. Instead we can delay the subscription to when the explain collection is enabled for the first time, meaning except for a few rare cases this code won't even be loaded in production environments. --- activerecord/lib/active_record/base.rb | 1 - activerecord/lib/active_record/explain.rb | 2 +- .../lib/active_record/explain_registry.rb | 52 ++++++++++++++++++- .../lib/active_record/explain_subscriber.rb | 34 ------------ .../test/cases/explain_subscriber_test.rb | 7 ++- 5 files changed, 55 insertions(+), 41 deletions(-) delete mode 100644 activerecord/lib/active_record/explain_subscriber.rb diff --git a/activerecord/lib/active_record/base.rb b/activerecord/lib/active_record/base.rb index 2b31845c8d12d..2d9e67e6e5ad3 100644 --- a/activerecord/lib/active_record/base.rb +++ b/activerecord/lib/active_record/base.rb @@ -6,7 +6,6 @@ require "active_support/time" require "active_support/core_ext/class/subclasses" require "active_record/log_subscriber" -require "active_record/explain_subscriber" require "active_record/relation/delegation" require "active_record/attributes" require "active_record/type_caster" diff --git a/activerecord/lib/active_record/explain.rb b/activerecord/lib/active_record/explain.rb index 20e939656de85..c133154bc1f4d 100644 --- a/activerecord/lib/active_record/explain.rb +++ b/activerecord/lib/active_record/explain.rb @@ -7,7 +7,7 @@ module Explain # Executes the block with the collect flag enabled. Queries are collected # asynchronously by the subscriber and returned. def collecting_queries_for_explain # :nodoc: - ExplainRegistry.collect = true + ExplainRegistry.start yield ExplainRegistry.queries ensure diff --git a/activerecord/lib/active_record/explain_registry.rb b/activerecord/lib/active_record/explain_registry.rb index 0ad582e91671e..3142db8faaaa6 100644 --- a/activerecord/lib/active_record/explain_registry.rb +++ b/activerecord/lib/active_record/explain_registry.rb @@ -8,8 +8,53 @@ module ActiveRecord # # returns the collected queries local to the current thread. class ExplainRegistry # :nodoc: + class Subscriber + MUTEX = Mutex.new + @subscribed = false + + class << self + def ensure_subscribed + return if @subscribed + MUTEX.synchronize do + return if @subscribed + + ActiveSupport::Notifications.subscribe("sql.active_record", new) + @subscribed = true + end + end + end + + def start(name, id, payload) + # unused + end + + def finish(name, id, payload) + if ExplainRegistry.collect? && !ignore_payload?(payload) + ExplainRegistry.queries << payload.values_at(:sql, :binds) + end + end + + def silenced?(_name) + !ExplainRegistry.collect? + end + + # SCHEMA queries cannot be EXPLAINed, also we do not want to run EXPLAIN on + # our own EXPLAINs no matter how loopingly beautiful that would be. + # + # On the other hand, we want to monitor the performance of our real database + # queries, not the performance of the access to the query cache. + IGNORED_PAYLOADS = %w(SCHEMA EXPLAIN) + EXPLAINED_SQLS = /\A\s*(\/\*.*\*\/)?\s*(with|select|update|delete|insert)\b/i + def ignore_payload?(payload) + payload[:exception] || + payload[:cached] || + IGNORED_PAYLOADS.include?(payload[:name]) || + !payload[:sql].match?(EXPLAINED_SQLS) + end + end + class << self - delegate :reset, :collect, :collect=, :collect?, :queries, to: :instance + delegate :start, :reset, :collect, :collect=, :collect?, :queries, to: :instance private def instance @@ -24,6 +69,11 @@ def initialize reset end + def start + Subscriber.ensure_subscribed + @collect = true + end + def collect? @collect end diff --git a/activerecord/lib/active_record/explain_subscriber.rb b/activerecord/lib/active_record/explain_subscriber.rb deleted file mode 100644 index d95dd6c4a3c91..0000000000000 --- a/activerecord/lib/active_record/explain_subscriber.rb +++ /dev/null @@ -1,34 +0,0 @@ -# frozen_string_literal: true - -require "active_support/notifications" -require "active_record/explain_registry" - -module ActiveRecord - class ExplainSubscriber # :nodoc: - def start(name, id, payload) - # unused - end - - def finish(name, id, payload) - if ExplainRegistry.collect? && !ignore_payload?(payload) - ExplainRegistry.queries << payload.values_at(:sql, :binds) - end - end - - # SCHEMA queries cannot be EXPLAINed, also we do not want to run EXPLAIN on - # our own EXPLAINs no matter how loopingly beautiful that would be. - # - # On the other hand, we want to monitor the performance of our real database - # queries, not the performance of the access to the query cache. - IGNORED_PAYLOADS = %w(SCHEMA EXPLAIN) - EXPLAINED_SQLS = /\A\s*(\/\*.*\*\/)?\s*(with|select|update|delete|insert)\b/i - def ignore_payload?(payload) - payload[:exception] || - payload[:cached] || - IGNORED_PAYLOADS.include?(payload[:name]) || - !payload[:sql].match?(EXPLAINED_SQLS) - end - - ActiveSupport::Notifications.subscribe("sql.active_record", new) - end -end diff --git a/activerecord/test/cases/explain_subscriber_test.rb b/activerecord/test/cases/explain_subscriber_test.rb index d7ec37fd42e3a..26133a5a2aa7d 100644 --- a/activerecord/test/cases/explain_subscriber_test.rb +++ b/activerecord/test/cases/explain_subscriber_test.rb @@ -1,16 +1,15 @@ # frozen_string_literal: true require "cases/helper" -require "active_record/explain_subscriber" require "active_record/explain_registry" if ActiveRecord::Base.lease_connection.supports_explain? class ExplainSubscriberTest < ActiveRecord::TestCase - SUBSCRIBER = ActiveRecord::ExplainSubscriber.new + SUBSCRIBER = ActiveRecord::ExplainRegistry::Subscriber.new def setup ActiveRecord::ExplainRegistry.reset - ActiveRecord::ExplainRegistry.collect = true + ActiveRecord::ExplainRegistry.start end def test_collects_nothing_if_the_payload_has_an_exception @@ -19,7 +18,7 @@ def test_collects_nothing_if_the_payload_has_an_exception end def test_collects_nothing_for_ignored_payloads - ActiveRecord::ExplainSubscriber::IGNORED_PAYLOADS.each do |ip| + ActiveRecord::ExplainRegistry::Subscriber::IGNORED_PAYLOADS.each do |ip| SUBSCRIBER.finish(nil, nil, name: ip) end assert_empty queries From 07854a55607f845a72c4a9d43d4bedfe954d038c Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Wed, 24 Sep 2025 15:29:09 +0200 Subject: [PATCH 0693/1075] Refactor preprocess_query to call `write_query?` only once This saves having to match the same regexp twice in a row. Ideally we wouldn't even use a regexp in most cases, and instead rely on the higher level hints, e.g. if `select_all` was used we should assume it's a read. --- .../abstract/database_statements.rb | 10 ++++++---- .../connection_adapters/abstract_adapter.rb | 4 ++-- 2 files changed, 8 insertions(+), 6 deletions(-) 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 08ced7f2abb98..cccf51c2d5fa3 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb @@ -388,10 +388,10 @@ def transaction(requires_new: nil, isolation: nil, joinable: true, &block) :disable_lazy_transactions!, :enable_lazy_transactions!, :dirty_current_transaction, to: :transaction_manager - def mark_transaction_written_if_write(sql) # :nodoc: + def mark_transaction_written # :nodoc: transaction = current_transaction if transaction.open? - transaction.written ||= write_query?(sql) + transaction.written ||= true end end @@ -592,8 +592,10 @@ def affected_rows(raw_result) end def preprocess_query(sql) - check_if_write_query(sql) - mark_transaction_written_if_write(sql) + if write_query?(sql) + ensure_writes_are_allowed(sql) + mark_transaction_written + end # We call tranformers after the write checks so we don't add extra parsing work. # This means we assume no transformer whille change a read for a write diff --git a/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb b/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb index 48d63feffb498..0bbb48857f83d 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb @@ -199,8 +199,8 @@ def lock_thread=(lock_thread) # :nodoc: end end - def check_if_write_query(sql) # :nodoc: - if preventing_writes? && write_query?(sql) + def ensure_writes_are_allowed(sql) # :nodoc: + if preventing_writes? raise ActiveRecord::ReadOnlyError, "Write query attempted while in readonly mode: #{sql}" end end From d918d4e21c5c6877f9007477331c1b0f654c0806 Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Tue, 23 Sep 2025 15:49:16 +0200 Subject: [PATCH 0694/1075] ActiveSupport::Notifications leverage `...` delegation Recent Rubies are able to perform `...` delegation with 0 allocations we should use it when possible. --- .../lib/active_support/notifications/fanout.rb | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/activesupport/lib/active_support/notifications/fanout.rb b/activesupport/lib/active_support/notifications/fanout.rb index ecf4b7c97b439..12987e95ac174 100644 --- a/activesupport/lib/active_support/notifications/fanout.rb +++ b/activesupport/lib/active_support/notifications/fanout.rb @@ -286,8 +286,8 @@ def finish(name, id, payload, listeners = nil) handle.finish_with_values(name, id, payload) end - def publish(name, *args) - iterate_guarding_exceptions(listeners_for(name)) { |s| s.publish(name, *args) } + def publish(name, ...) + iterate_guarding_exceptions(listeners_for(name)) { |s| s.publish(name, ...) } end def publish_event(event) @@ -387,9 +387,9 @@ def group_class EventedGroup end - def publish(name, *args) + def publish(...) if @can_publish - @delegate.publish name, *args + @delegate.publish(...) end end @@ -419,8 +419,8 @@ def group_class TimedGroup end - def publish(name, *args) - @delegate.call name, *args + def publish(...) + @delegate.call(...) end end From d715f26f0921f7ef8427a6882a0db193cc64d45c Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Thu, 25 Sep 2025 10:51:37 +0200 Subject: [PATCH 0695/1075] ActiveRecord::AbstractAdapter: don't use callbacks for normal operations Callbacks have a fairly significant overhead, and don't really make the code easier to follow. They are useful to allow third party code to hook into the connections lifecycle, but there isn't a big reason for internal code to use them rather than a regular method call. --- .../abstract/connection_pool.rb | 11 ++--------- .../connection_adapters/abstract/query_cache.rb | 2 -- .../connection_adapters/abstract_adapter.rb | 17 +++++++++++------ 3 files changed, 13 insertions(+), 17 deletions(-) diff --git a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb index a68585fca8b20..269a93434b8a7 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb @@ -651,11 +651,7 @@ def checkin(conn) conn.lock.synchronize do synchronize do connection_lease.clear(conn) - - conn._run_checkin_callbacks do - conn.expire - end - + conn.expire @available.add conn end end @@ -1265,10 +1261,7 @@ def checkout_new_connection end def checkout_and_verify(c) - c._run_checkout_callbacks do - c.clean! - end - c + c.clean! rescue Exception remove c c.disconnect! diff --git a/activerecord/lib/active_record/connection_adapters/abstract/query_cache.rb b/activerecord/lib/active_record/connection_adapters/abstract/query_cache.rb index 187035e89e0a8..a3d554d1cf684 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/query_cache.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/query_cache.rb @@ -13,8 +13,6 @@ def included(base) # :nodoc: dirties_query_cache base, :exec_query, :execute, :create, :insert, :update, :delete, :truncate, :truncate_tables, :rollback_to_savepoint, :rollback_db_transaction, :restart_db_transaction, :exec_insert_all - - base.set_callback :checkin, :after, :unset_query_cache! end def dirties_query_cache(base, *method_names) diff --git a/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb b/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb index 0bbb48857f83d..328cc450c7842 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb @@ -53,8 +53,6 @@ def pool=(value) @pool = value end - set_callback :checkin, :after, :enable_lazy_transactions! - def self.type_cast_config_to_integer(config) if config.is_a?(Integer) config @@ -330,8 +328,12 @@ def expire(update_idle = true) # :nodoc: "Current thread: #{ActiveSupport::IsolatedExecutionState.context}." end - @idle_since = Process.clock_gettime(Process::CLOCK_MONOTONIC) if update_idle - @owner = nil + _run_checkin_callbacks do + @idle_since = Process.clock_gettime(Process::CLOCK_MONOTONIC) if update_idle + @owner = nil + enable_lazy_transactions! + unset_query_cache! + end else raise ActiveRecordError, "Cannot expire connection, it is not currently leased." end @@ -828,8 +830,11 @@ def connect! end def clean! # :nodoc: - @raw_connection_dirty = false - @verified = nil + _run_checkout_callbacks do + @raw_connection_dirty = false + @verified = nil + end + self end def verified? # :nodoc: From 3f669e60940e323e33e751d0070acaf2566ec432 Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Thu, 25 Sep 2025 10:55:07 +0200 Subject: [PATCH 0696/1075] ActiveRecord LeaseRegistry: skip locking on MRI Thanks to the GVL, we don't need that mutex when running on MRI. This remove a little bit of overhead on every connection checkout and checkin. --- .../abstract/connection_pool.rb | 31 ++++++++++++------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb index 269a93434b8a7..d6d37409ba764 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb @@ -179,21 +179,30 @@ def clear(connection) end end - class LeaseRegistry # :nodoc: - def initialize - @mutex = Mutex.new - @map = WeakThreadKeyMap.new + if RUBY_ENGINE == "ruby" + # Thanks to the GVL, the LeaseRegistry doesn't need to be synchronized on MRI + class LeaseRegistry < WeakThreadKeyMap # :nodoc: + def [](context) + super || (self[context] = Lease.new) + end end + else + class LeaseRegistry # :nodoc: + def initialize + @mutex = Mutex.new + @map = WeakThreadKeyMap.new + end - def [](context) - @mutex.synchronize do - @map[context] ||= Lease.new + def [](context) + @mutex.synchronize do + @map[context] ||= Lease.new + end end - end - def clear - @mutex.synchronize do - @map.clear + def clear + @mutex.synchronize do + @map.clear + end end end end From 3ce38ae35ce0f0f7d5ad6dc7bba2b7c95ce55a31 Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Thu, 25 Sep 2025 11:05:16 +0200 Subject: [PATCH 0697/1075] Micro-optimize ActiveSupport::Notifications - Adds a fastpath in `iterate_guarding_exceptions` for when there's only a single subscriber. In such case we don't need any fancy exception handling. - Merge the `groups_for` and `silenceable_group_for` caches, as to fetch both records with a single lookup. - Return a null object in `build_handle` if there are no subscribers --- .../active_support/notifications/fanout.rb | 94 ++++++++++++------- 1 file changed, 58 insertions(+), 36 deletions(-) diff --git a/activesupport/lib/active_support/notifications/fanout.rb b/activesupport/lib/active_support/notifications/fanout.rb index 12987e95ac174..a70691f9aaa23 100644 --- a/activesupport/lib/active_support/notifications/fanout.rb +++ b/activesupport/lib/active_support/notifications/fanout.rb @@ -17,24 +17,30 @@ def initialize(exceptions) module FanoutIteration # :nodoc: private - def iterate_guarding_exceptions(collection) - exceptions = nil - - collection.each do |s| - yield s - rescue Exception => e - exceptions ||= [] - exceptions << e - end + def iterate_guarding_exceptions(collection, &block) + case collection.size + when 0 + when 1 + collection.each(&block) + else + exceptions = nil - if exceptions - exceptions = exceptions.flat_map do |exception| - exception.is_a?(InstrumentationSubscriberError) ? exception.exceptions : [exception] + collection.each do |s| + yield s + rescue Exception => e + exceptions ||= [] + exceptions << e end - if exceptions.size == 1 - raise exceptions.first - else - raise InstrumentationSubscriberError.new(exceptions), cause: exceptions.first + + if exceptions + exceptions = exceptions.flat_map do |exception| + exception.is_a?(InstrumentationSubscriberError) ? exception.exceptions : [exception] + end + if exceptions.size == 1 + raise exceptions.first + else + raise InstrumentationSubscriberError.new(exceptions), cause: exceptions.first + end end end @@ -53,7 +59,6 @@ def initialize @other_subscribers = [] @all_listeners_for = Concurrent::Map.new @groups_for = Concurrent::Map.new - @silenceable_groups_for = Concurrent::Map.new end def inspect # :nodoc: @@ -102,11 +107,9 @@ def clear_cache(key = nil) # :nodoc: if key @all_listeners_for.delete(key) @groups_for.delete(key) - @silenceable_groups_for.delete(key) else @all_listeners_for.clear @groups_for.clear - @silenceable_groups_for.clear end end @@ -184,25 +187,25 @@ def build_event(name, id, payload) end end - def groups_for(name) # :nodoc: - groups = @groups_for.compute_if_absent(name) do - all_listeners_for(name).reject(&:silenceable).group_by(&:group_class).transform_values do |s| - s.map(&:delegate) - end - end + def group_listeners(listeners) + listeners.group_by(&:group_class).transform_values do |s| + s.map(&:delegate).freeze + end.freeze + end - silenceable_groups = @silenceable_groups_for.compute_if_absent(name) do - all_listeners_for(name).select(&:silenceable).group_by(&:group_class).transform_values do |s| - s.map(&:delegate) - end + def groups_for(name) # :nodoc: + silenceable_groups, groups = @groups_for.compute_if_absent(name) do + listeners = all_listeners_for(name) + listeners.partition(&:silenceable).map { |l| group_listeners(l) } end unless silenceable_groups.empty? - groups = groups.dup silenceable_groups.each do |group_class, subscriptions| active_subscriptions = subscriptions.reject { |s| s.silenced?(name) } unless active_subscriptions.empty? - groups[group_class] = (groups[group_class] || []) + active_subscriptions + groups = groups.dup if groups.frozen? + base_groups = groups[group_class] + groups[group_class] = base_groups ? base_groups + active_subscriptions : active_subscriptions end end end @@ -227,13 +230,11 @@ def groups_for(name) # :nodoc: class Handle include FanoutIteration - def initialize(notifier, name, id, payload) # :nodoc: + def initialize(notifier, name, id, groups, payload) # :nodoc: @name = name @id = id @payload = payload - @groups = notifier.groups_for(name).map do |group_klass, grouped_listeners| - group_klass.new(grouped_listeners, name, id, payload) - end + @groups = groups @state = :initialized end @@ -267,10 +268,31 @@ def ensure_state!(expected) end end + module NullHandle # :nodoc: + extend self + + def start + end + + def finish + end + + def finish_with_values(_name, _id, _payload) + end + end + include FanoutIteration def build_handle(name, id, payload) - Handle.new(self, name, id, payload) + groups = groups_for(name).map do |group_klass, grouped_listeners| + group_klass.new(grouped_listeners, name, id, payload) + end + + if groups.empty? + NullHandle + else + Handle.new(self, name, id, groups, payload) + end end def start(name, id, payload) From 7d12071e9fe94bd5c01a488ef61718fe88de65b4 Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Thu, 25 Sep 2025 10:58:49 +0200 Subject: [PATCH 0698/1075] Micro optimize ActiveRecord::RuntimeRegistry Accessing `IsolatedExecutionState` is a bit costly, hence it's prefereable to minimize accesses. By moving all 4 RuntimeRegistry metrics in a consolidated object, we now only access the `IsolatedExecutionState` once per query while before it was up to 8 times. --- .../activerecord/controller_runtime_test.rb | 6 +- .../railties/controller_runtime.rb | 17 ++-- .../lib/active_record/railties/job_runtime.rb | 4 +- .../lib/active_record/runtime_registry.rb | 99 ++++++++----------- .../test/activejob/job_runtime_test.rb | 8 +- 5 files changed, 61 insertions(+), 73 deletions(-) diff --git a/actionview/test/activerecord/controller_runtime_test.rb b/actionview/test/activerecord/controller_runtime_test.rb index aa0a20af891e0..ab08aa3cef971 100644 --- a/actionview/test/activerecord/controller_runtime_test.rb +++ b/actionview/test/activerecord/controller_runtime_test.rb @@ -19,7 +19,7 @@ def zero end def create - ActiveRecord::RuntimeRegistry.sql_runtime += 100.0 + ActiveRecord::RuntimeRegistry.stats.sql_runtime += 100.0 Project.last redirect_to "/" end @@ -32,7 +32,7 @@ def redirect def db_after_render render inline: "Hello world" Project.all.to_a - ActiveRecord::RuntimeRegistry.sql_runtime += 100.0 + ActiveRecord::RuntimeRegistry.stats.sql_runtime += 100.0 end end @@ -72,7 +72,7 @@ def test_log_with_active_record end def test_runtime_reset_before_requests - ActiveRecord::RuntimeRegistry.sql_runtime += 12345.0 + ActiveRecord::RuntimeRegistry.stats.sql_runtime += 12345.0 get :zero wait diff --git a/activerecord/lib/active_record/railties/controller_runtime.rb b/activerecord/lib/active_record/railties/controller_runtime.rb index a9ec0e70f049a..4ccc02878e14a 100644 --- a/activerecord/lib/active_record/railties/controller_runtime.rb +++ b/activerecord/lib/active_record/railties/controller_runtime.rb @@ -41,11 +41,14 @@ def process_action(action, *args) def cleanup_view_runtime if logger && logger.info? - db_rt_before_render = ActiveRecord::RuntimeRegistry.reset_runtimes + runtime_stats = ActiveRecord::RuntimeRegistry.stats + db_rt_before_render = runtime_stats.reset_runtimes self.db_runtime = (db_runtime || 0) + db_rt_before_render + runtime = super - queries_rt = ActiveRecord::RuntimeRegistry.sql_runtime - ActiveRecord::RuntimeRegistry.async_sql_runtime - db_rt_after_render = ActiveRecord::RuntimeRegistry.reset_runtimes + + queries_rt = runtime_stats.sql_runtime - runtime_stats.async_sql_runtime + db_rt_after_render = runtime_stats.reset_runtimes self.db_runtime += db_rt_after_render runtime - queries_rt else @@ -56,9 +59,11 @@ def cleanup_view_runtime def append_info_to_payload(payload) super - payload[:db_runtime] = (db_runtime || 0) + ActiveRecord::RuntimeRegistry.reset_runtimes - payload[:queries_count] = ActiveRecord::RuntimeRegistry.reset_queries_count - payload[:cached_queries_count] = ActiveRecord::RuntimeRegistry.reset_cached_queries_count + runtime_stats = ActiveRecord::RuntimeRegistry.stats + payload[:db_runtime] = (db_runtime || 0) + runtime_stats.sql_runtime + payload[:queries_count] = runtime_stats.queries_count + payload[:cached_queries_count] = runtime_stats.cached_queries_count + runtime_stats.reset end end end diff --git a/activerecord/lib/active_record/railties/job_runtime.rb b/activerecord/lib/active_record/railties/job_runtime.rb index f1d84ddf533cb..61cde5308bad8 100644 --- a/activerecord/lib/active_record/railties/job_runtime.rb +++ b/activerecord/lib/active_record/railties/job_runtime.rb @@ -8,9 +8,9 @@ module JobRuntime # :nodoc: def instrument(operation, payload = {}, &block) # :nodoc: if operation == :perform && block super(operation, payload) do - db_runtime_before_perform = ActiveRecord::RuntimeRegistry.sql_runtime + db_runtime_before_perform = ActiveRecord::RuntimeRegistry.stats.sql_runtime result = block.call - payload[:db_runtime] = ActiveRecord::RuntimeRegistry.sql_runtime - db_runtime_before_perform + payload[:db_runtime] = ActiveRecord::RuntimeRegistry.stats.sql_runtime - db_runtime_before_perform result end else diff --git a/activerecord/lib/active_record/runtime_registry.rb b/activerecord/lib/active_record/runtime_registry.rb index 3b35779de2db4..0e10d7581aec4 100644 --- a/activerecord/lib/active_record/runtime_registry.rb +++ b/activerecord/lib/active_record/runtime_registry.rb @@ -3,80 +3,63 @@ module ActiveRecord # This is a thread locals registry for Active Record. For example: # - # ActiveRecord::RuntimeRegistry.sql_runtime + # ActiveRecord::RuntimeRegistry.stats.sql_runtime # # returns the connection handler local to the current unit of execution (either thread of fiber). module RuntimeRegistry # :nodoc: - extend self - - def sql_runtime - ActiveSupport::IsolatedExecutionState[:active_record_sql_runtime] ||= 0.0 + class Stats + attr_accessor :sql_runtime, :async_sql_runtime, :queries_count, :cached_queries_count + + def initialize + @sql_runtime = 0.0 + @async_sql_runtime = 0.0 + @queries_count = 0 + @cached_queries_count = 0 + end + + def reset_runtimes + sql_runtime_was = @sql_runtime + @sql_runtime = 0.0 + @async_sql_runtime = 0.0 + sql_runtime_was + end + + public alias_method :reset, :initialize end - def sql_runtime=(runtime) - ActiveSupport::IsolatedExecutionState[:active_record_sql_runtime] = runtime - end - - def async_sql_runtime - ActiveSupport::IsolatedExecutionState[:active_record_async_sql_runtime] ||= 0.0 - end + extend self - def async_sql_runtime=(runtime) - ActiveSupport::IsolatedExecutionState[:active_record_async_sql_runtime] = runtime + def call(name, start, finish, id, payload) + record( + payload[:name], + (finish - start) * 1_000.0, + async: payload[:async], + lock_wait: payload[:lock_wait], + ) end - def queries_count - ActiveSupport::IsolatedExecutionState[:active_record_queries_count] ||= 0 - end + def record(query_name, runtime, cached: false, async: false, lock_wait: nil) + stats = self.stats - def queries_count=(count) - ActiveSupport::IsolatedExecutionState[:active_record_queries_count] = count - end + unless query_name == "TRANSACTION" || query_name == "SCHEMA" + stats.queries_count += 1 + stats.cached_queries_count += 1 if cached + end - def cached_queries_count - ActiveSupport::IsolatedExecutionState[:active_record_cached_queries_count] ||= 0 + if async + stats.async_sql_runtime += (runtime - lock_wait) + end + stats.sql_runtime += runtime end - def cached_queries_count=(count) - ActiveSupport::IsolatedExecutionState[:active_record_cached_queries_count] = count + def stats + ActiveSupport::IsolatedExecutionState[:active_record_runtime] ||= Stats.new end def reset - reset_runtimes - reset_queries_count - reset_cached_queries_count - end - - def reset_runtimes - rt, self.sql_runtime = sql_runtime, 0.0 - self.async_sql_runtime = 0.0 - rt - end - - def reset_queries_count - qc = queries_count - self.queries_count = 0 - qc - end - - def reset_cached_queries_count - qc = cached_queries_count - self.cached_queries_count = 0 - qc + stats.reset end end end -ActiveSupport::Notifications.monotonic_subscribe("sql.active_record") do |name, start, finish, id, payload| - unless ["SCHEMA", "TRANSACTION"].include?(payload[:name]) - ActiveRecord::RuntimeRegistry.queries_count += 1 - ActiveRecord::RuntimeRegistry.cached_queries_count += 1 if payload[:cached] - end - - runtime = (finish - start) * 1_000.0 - - if payload[:async] - ActiveRecord::RuntimeRegistry.async_sql_runtime += (runtime - payload[:lock_wait]) - end - ActiveRecord::RuntimeRegistry.sql_runtime += runtime -end +ActiveSupport::Notifications.monotonic_subscribe("sql.active_record", ActiveRecord::RuntimeRegistry) diff --git a/activerecord/test/activejob/job_runtime_test.rb b/activerecord/test/activejob/job_runtime_test.rb index 12c4a88a258be..8ee5b2fc21d06 100644 --- a/activerecord/test/activejob/job_runtime_test.rb +++ b/activerecord/test/activejob/job_runtime_test.rb @@ -8,21 +8,21 @@ class TestJob < ActiveJob::Base include ActiveRecord::Railties::JobRuntime def perform(*) - ActiveRecord::RuntimeRegistry.sql_runtime += 42.0 + ActiveRecord::RuntimeRegistry.stats.sql_runtime += 42.0 end end test "job notification payload includes db_runtime" do - ActiveRecord::RuntimeRegistry.sql_runtime = 0.0 + ActiveRecord::RuntimeRegistry.stats.sql_runtime = 0.0 assert_notification("perform.active_job", db_runtime: 42.0) { TestJob.perform_now } end test "db_runtime tracks database runtime for job only" do - ActiveRecord::RuntimeRegistry.sql_runtime = 100.0 + ActiveRecord::RuntimeRegistry.stats.sql_runtime = 100.0 assert_notification("perform.active_job", db_runtime: 42.0) { TestJob.perform_now } - assert_equal 142.0, ActiveRecord::RuntimeRegistry.sql_runtime + assert_equal 142.0, ActiveRecord::RuntimeRegistry.stats.sql_runtime end end From bab99f8b51dfbff4d9e161b8f23c0dbf5d3b9314 Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Thu, 25 Sep 2025 13:01:49 +0200 Subject: [PATCH 0699/1075] Mark `group_listeners` as private API --- activesupport/lib/active_support/notifications/fanout.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/activesupport/lib/active_support/notifications/fanout.rb b/activesupport/lib/active_support/notifications/fanout.rb index a70691f9aaa23..4df967158f385 100644 --- a/activesupport/lib/active_support/notifications/fanout.rb +++ b/activesupport/lib/active_support/notifications/fanout.rb @@ -187,7 +187,7 @@ def build_event(name, id, payload) end end - def group_listeners(listeners) + def group_listeners(listeners) # :nodoc: listeners.group_by(&:group_class).transform_values do |s| s.map(&:delegate).freeze end.freeze From 50999e0afd571e359ae5e26ad09c4e91a9e39c45 Mon Sep 17 00:00:00 2001 From: Earlopain <14981592+Earlopain@users.noreply.github.com> Date: Thu, 25 Sep 2025 14:19:05 +0200 Subject: [PATCH 0700/1075] Remove `cgi` from the gemfile again rouge 4.6.1 has been released, so this is no longer necessary --- Gemfile | 2 -- Gemfile.lock | 4 +--- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/Gemfile b/Gemfile index 10b027fd2458c..9252ddd426b93 100644 --- a/Gemfile +++ b/Gemfile @@ -66,8 +66,6 @@ group :doc do gem "redcarpet", "~> 3.6.1", platforms: :ruby gem "w3c_validators", "~> 1.3.6" gem "rouge" - # Workaround until https://github.com/rouge-ruby/rouge/pull/2131 is merged and released - gem "cgi", require: false gem "rubyzip", "~> 2.0" end diff --git a/Gemfile.lock b/Gemfile.lock index 6f8af96e771bb..1065d86e8c5fc 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -167,7 +167,6 @@ GEM rack-test (>= 0.6.3) regexp_parser (>= 1.5, < 3.0) xpath (~> 3.2) - cgi (0.5.0) chef-utils (18.6.2) concurrent-ruby childprocess (5.1.0) @@ -517,7 +516,7 @@ GEM rufus-scheduler (~> 3.2, != 3.3) retriable (3.1.2) rexml (3.4.0) - rouge (4.6.0) + rouge (4.6.1) rubocop (1.79.2) json (~> 2.3) language_server-protocol (~> 3.17.0.2) @@ -758,7 +757,6 @@ DEPENDENCIES brakeman bundler-audit capybara (>= 3.39) - cgi connection_pool cssbundling-rails dalli (>= 3.0.1) From fed08be767b2494950c1c2b5489f77136504221a Mon Sep 17 00:00:00 2001 From: Matthew Draper Date: Sat, 6 Sep 2025 15:13:03 +0200 Subject: [PATCH 0701/1075] Setting max_connections to nil means no limit No change to the default of 5 at this stage, just making sure there is a way to explicitly specify "unlimited" without needing to use an arbitrary large number. --- .../abstract/connection_pool.rb | 3 +- .../database_configurations/hash_config.rb | 7 ++- .../test/cases/connection_pool_test.rb | 63 +++++++++++++++++++ .../hash_config_test.rb | 26 +++++++- 4 files changed, 93 insertions(+), 6 deletions(-) diff --git a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb index d6d37409ba764..bf66a68ff82bd 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract/connection_pool.rb @@ -112,6 +112,7 @@ def pool_transaction_isolation_level=(isolation_level) # * +max_age+: number of seconds the pool will allow the connection to # exist before retiring it at next checkin. (default Float::INFINITY). # * +max_connections+: maximum number of connections the pool may manage (default 5). + # Set to +nil+ or -1 for unlimited connections. # * +min_connections+: minimum number of connections the pool will open and maintain (default 0). # * +pool_jitter+: maximum reduction factor to apply to +max_age+ and # +keepalive+ intervals (default 0.2; range 0.0-1.0). @@ -1223,7 +1224,7 @@ def try_to_checkout_new_connection do_checkout = synchronize do return if self.discarded? - if @threads_blocking_new_connections.zero? && (@connections.size + @now_connecting) < @max_connections && (!block_given? || yield) + if @threads_blocking_new_connections.zero? && (@max_connections.nil? || (@connections.size + @now_connecting) < @max_connections) && (!block_given? || yield) if @connections.size > 0 || @original_context != ActiveSupport::IsolatedExecutionState.context @activated = true end diff --git a/activerecord/lib/active_record/database_configurations/hash_config.rb b/activerecord/lib/active_record/database_configurations/hash_config.rb index 9282a44fd02b4..adc2095de1ad1 100644 --- a/activerecord/lib/active_record/database_configurations/hash_config.rb +++ b/activerecord/lib/active_record/database_configurations/hash_config.rb @@ -71,7 +71,10 @@ def _database=(database) # :nodoc: end def max_connections - (configuration_hash[:max_connections] || configuration_hash[:pool] || 5).to_i + max_connections = configuration_hash.fetch(:max_connections) { + configuration_hash.fetch(:pool, 5) + }&.to_i + max_connections if max_connections && max_connections >= 0 end def min_connections @@ -86,7 +89,7 @@ def min_threads end def max_threads - (configuration_hash[:max_threads] || max_connections).to_i + (configuration_hash[:max_threads] || (max_connections || 5).clamp(0, 5)).to_i end def max_age diff --git a/activerecord/test/cases/connection_pool_test.rb b/activerecord/test/cases/connection_pool_test.rb index 9ff204432ce22..97673eb85fed8 100644 --- a/activerecord/test/cases/connection_pool_test.rb +++ b/activerecord/test/cases/connection_pool_test.rb @@ -1498,6 +1498,69 @@ def test_checkout_callback_error_does_not_affect_permanent_lease_and_active_conn ActiveRecord::ConnectionAdapters::AbstractAdapter.skip_callback(:checkout, :after, proc_to_raise) end + def test_unlimited_connections_with_nil + pool = new_pool_with_options(max_connections: nil) + + assert_nil pool.max_connections + assert_nil pool.size + + # Should be able to create many connections without hitting a limit + connections = [] + 10.times do + connections << pool.checkout + end + + assert_equal 10, connections.size + connections.each { |conn| pool.checkin(conn) } + ensure + pool&.disconnect! + end + + def test_unlimited_connections_with_negative_one + pool = new_pool_with_options(max_connections: -1) + + assert_nil pool.max_connections + assert_nil pool.size + + # Should be able to create many connections without hitting a limit + connections = [] + 10.times do + connections << pool.checkout + end + + assert_equal 10, connections.size + connections.each { |conn| pool.checkin(conn) } + ensure + pool&.disconnect! + end + + def test_zero_connections_means_zero_limit + pool = new_pool_with_options(max_connections: 0) + + assert_equal 0, pool.max_connections + assert_equal 0, pool.size + + # Should not be able to create any connections + error = assert_raises(ActiveRecord::ConnectionTimeoutError) do + pool.checkout + end + assert_match(/could not obtain a connection from the pool/, error.message) + ensure + pool&.disconnect! + end + + def test_unlimited_connections_stat_reports_nil_size + pool = new_pool_with_options(max_connections: nil) + + stat = pool.stat + assert_nil stat[:size] + assert_equal 0, stat[:connections] + assert_equal 0, stat[:busy] + assert_equal 0, stat[:idle] + ensure + pool&.disconnect! + end + private def active_connections(pool) pool.connections.find_all(&:in_use?) diff --git a/activerecord/test/cases/database_configurations/hash_config_test.rb b/activerecord/test/cases/database_configurations/hash_config_test.rb index f92525c5a807a..84b526720ee8f 100644 --- a/activerecord/test/cases/database_configurations/hash_config_test.rb +++ b/activerecord/test/cases/database_configurations/hash_config_test.rb @@ -64,21 +64,41 @@ def test_when_no_keepalive_uses_default assert_equal 600, config.keepalive end - def test_max_connections_default_when_nil + def test_max_connections_unlimited_when_nil config = HashConfig.new("default_env", "primary", max_connections: nil, adapter: "abstract") - assert_equal 5, config.max_connections + assert_nil config.max_connections end - def test_max_connections_overrides_with_value + def test_max_connections_unlimited_when_negative_one + config = HashConfig.new("default_env", "primary", max_connections: "-1", adapter: "abstract") + assert_nil config.max_connections + end + + def test_max_connections_zero_means_zero config = HashConfig.new("default_env", "primary", max_connections: "0", adapter: "abstract") assert_equal 0, config.max_connections end + def test_max_connections_overrides_with_value + config = HashConfig.new("default_env", "primary", max_connections: "10", adapter: "abstract") + assert_equal 10, config.max_connections + end + def test_when_no_max_connections_uses_default config = HashConfig.new("default_env", "primary", adapter: "abstract") assert_equal 5, config.max_connections end + def test_max_threads_fallback_when_unlimited_connections + config = HashConfig.new("default_env", "primary", max_connections: nil, adapter: "abstract") + assert_equal 5, config.max_threads + end + + def test_max_threads_fallback_when_negative_connections + config = HashConfig.new("default_env", "primary", max_connections: -1, adapter: "abstract") + assert_equal 5, config.max_threads + end + def test_min_connections_default_when_nil config = HashConfig.new("default_env", "primary", min_connections: nil, adapter: "abstract") assert_equal 0, config.min_connections From f7e7080eba1e6cb6802994e65ddb5e71127d9f0b Mon Sep 17 00:00:00 2001 From: Jill Klang Date: Thu, 25 Sep 2025 12:44:28 -0400 Subject: [PATCH 0702/1075] Move implementation fully into #index_serializers To avoid confusion, instead of a raise > rescue > warn, we are moving things so that this method will more directly handle the #klass reference in this deprecation cycle --- activejob/lib/active_job/serializers.rb | 17 ++++++++--------- .../active_job/serializers/object_serializer.rb | 5 ----- 2 files changed, 8 insertions(+), 14 deletions(-) diff --git a/activejob/lib/active_job/serializers.rb b/activejob/lib/active_job/serializers.rb index 4bd44bcb7c763..0888974a4354e 100644 --- a/activejob/lib/active_job/serializers.rb +++ b/activejob/lib/active_job/serializers.rb @@ -76,15 +76,7 @@ def index_serializers @serializers_index.clear serializers.each do |s| if s.respond_to?(:klass) - begin - @serializers_index[s.klass] = s - rescue NotImplementedError => e - ActiveJob.deprecator.warn( - <<~MSG.squish - #{e.message}. This will raise an error in Rails 8.2. - MSG - ) - end + @serializers_index[s.klass] = s elsif s.respond_to?(:klass, true) klass = s.send(:klass) ActiveJob.deprecator.warn(<<~MSG.squish) @@ -92,6 +84,13 @@ def index_serializers This will raise an error in Rails 8.2. MSG @serializers_index[klass] = s + else + ActiveJob.deprecator.warn( + <<~MSG.squish + #{s.class.name} should implement a public #klass method. + This will raise an error in Rails 8.2. + MSG + ) end end end diff --git a/activejob/lib/active_job/serializers/object_serializer.rb b/activejob/lib/active_job/serializers/object_serializer.rb index ad2d1c6df9edd..98cf69be97ca4 100644 --- a/activejob/lib/active_job/serializers/object_serializer.rb +++ b/activejob/lib/active_job/serializers/object_serializer.rb @@ -47,11 +47,6 @@ def serialize(hash) def deserialize(hash) raise NotImplementedError, "#{self.class.name} should implement a public #deserialize(hash) method" end - - # The class of the object that will be serialized. - def klass - raise NotImplementedError, "#{self.class.name} should implement a public #klass method" - end end end end From 39425b1009dab86d41c5f826cb0c96c38c623a9d Mon Sep 17 00:00:00 2001 From: Dennis Paagman Date: Sat, 6 Jul 2024 21:16:54 +0200 Subject: [PATCH 0703/1075] Add setting for logging redirect source locations --- actionpack/CHANGELOG.md | 13 ++++++++++ .../lib/action_controller/log_subscriber.rb | 12 ++++++++++ actionpack/lib/action_controller/railtie.rb | 6 +++++ actionpack/lib/action_dispatch.rb | 8 +++++++ .../lib/action_dispatch/log_subscriber.rb | 13 ++++++++++ actionpack/lib/action_dispatch/railtie.rb | 7 ++++++ .../test/controller/log_subscriber_test.rb | 16 +++++++++++++ .../dispatch/routing/log_subscriber_test.rb | 24 +++++++++++++++++++ guides/source/configuring.md | 4 ++++ guides/source/debugging_rails_applications.md | 17 +++++++++++++ .../config/environments/development.rb.tt | 3 +++ .../test/application/configuration_test.rb | 13 ++++++++++ 12 files changed, 136 insertions(+) diff --git a/actionpack/CHANGELOG.md b/actionpack/CHANGELOG.md index c4c37e823c0ab..a4b765cfcfd1d 100644 --- a/actionpack/CHANGELOG.md +++ b/actionpack/CHANGELOG.md @@ -1,3 +1,16 @@ +* Add `action_dispatch.verbose_redirect_logs` setting that logs where redirects were called from. + + Similar to `active_record.verbose_query_logs` and `active_job.verbose_enqueue_logs`, this adds a line in your logs that shows where a redirect was called from. + + Example: + + ``` + Redirected to http://localhost:3000/posts/1 + ↳ app/controllers/posts_controller.rb:32:in `block (2 levels) in create' + ``` + + *Dennis Paagman* + * Add engine route filtering and better formatting in `bin/rails routes`. Allow engine routes to be filterable in the routing inspector, and diff --git a/actionpack/lib/action_controller/log_subscriber.rb b/actionpack/lib/action_controller/log_subscriber.rb index 8ade5817f5d6c..2838ad1c0278c 100644 --- a/actionpack/lib/action_controller/log_subscriber.rb +++ b/actionpack/lib/action_controller/log_subscriber.rb @@ -4,6 +4,8 @@ module ActionController class LogSubscriber < ActiveSupport::LogSubscriber # :nodoc: INTERNAL_PARAMS = %w(controller action format _method only_path) + class_attribute :backtrace_cleaner, default: ActiveSupport::BacktraceCleaner.new + def start_processing(event) return unless logger.info? @@ -61,6 +63,12 @@ def send_file(event) def redirect_to(event) info { "Redirected to #{event.payload[:location]}" } + + info do + if ActionDispatch.verbose_redirect_logs && (source = redirect_source_location) + "↳ #{source}" + end + end end subscribe_log_level :redirect_to, :info @@ -95,6 +103,10 @@ def #{method}(event) def logger ActionController::Base.logger end + + def redirect_source_location + backtrace_cleaner.first_clean_frame + end end end diff --git a/actionpack/lib/action_controller/railtie.rb b/actionpack/lib/action_controller/railtie.rb index a2e648104009f..c2695aea54820 100644 --- a/actionpack/lib/action_controller/railtie.rb +++ b/actionpack/lib/action_controller/railtie.rb @@ -148,5 +148,11 @@ class Railtie < Rails::Railtie # :nodoc: ActionController::TestCase.executor_around_each_request = app.config.active_support.executor_around_test_case end end + + initializer "action_controller.backtrace_cleaner" do + ActiveSupport.on_load(:action_controller) do + ActionController::LogSubscriber.backtrace_cleaner = Rails.backtrace_cleaner + end + end end end diff --git a/actionpack/lib/action_dispatch.rb b/actionpack/lib/action_dispatch.rb index 24107c901b15c..c24348d13e7e5 100644 --- a/actionpack/lib/action_dispatch.rb +++ b/actionpack/lib/action_dispatch.rb @@ -138,6 +138,14 @@ def self.resolve_store(session_store) # :nodoc: autoload :SystemTestCase, "action_dispatch/system_test_case" + ## + # :singleton-method: + # + # Specifies if the methods calling redirects in controllers and routes should + # be logged below their relevant log lines. Defaults to false. + singleton_class.attr_accessor :verbose_redirect_logs + self.verbose_redirect_logs = false + def eager_load! super Routing.eager_load! diff --git a/actionpack/lib/action_dispatch/log_subscriber.rb b/actionpack/lib/action_dispatch/log_subscriber.rb index 933f6bff8f9cf..4298bfc065550 100644 --- a/actionpack/lib/action_dispatch/log_subscriber.rb +++ b/actionpack/lib/action_dispatch/log_subscriber.rb @@ -2,11 +2,19 @@ module ActionDispatch class LogSubscriber < ActiveSupport::LogSubscriber # :nodoc: + class_attribute :backtrace_cleaner, default: ActiveSupport::BacktraceCleaner.new + def redirect(event) payload = event.payload info { "Redirected to #{payload[:location]}" } + info do + if ActionDispatch.verbose_redirect_logs && (source = redirect_source_location) + "↳ #{source}" + end + end + info do status = payload[:status] @@ -17,6 +25,11 @@ def redirect(event) end end subscribe_log_level :redirect, :info + + private + def redirect_source_location + backtrace_cleaner.first_clean_frame + end end end diff --git a/actionpack/lib/action_dispatch/railtie.rb b/actionpack/lib/action_dispatch/railtie.rb index 20a775d53cdfb..57180a9c41d25 100644 --- a/actionpack/lib/action_dispatch/railtie.rb +++ b/actionpack/lib/action_dispatch/railtie.rb @@ -34,6 +34,7 @@ class Railtie < Rails::Railtie # :nodoc: config.action_dispatch.ignore_leading_brackets = nil config.action_dispatch.strict_query_string_separator = nil + config.action_dispatch.verbose_redirect_logs = false config.action_dispatch.default_headers = { "X-Frame-Options" => "SAMEORIGIN", @@ -67,6 +68,8 @@ class Railtie < Rails::Railtie # :nodoc: ActionDispatch::QueryParser.strict_query_string_separator = app.config.action_dispatch.strict_query_string_separator end + ActionDispatch.verbose_redirect_logs = app.config.action_dispatch.verbose_redirect_logs + ActiveSupport.on_load(:action_dispatch_request) do self.ignore_accept_header = app.config.action_dispatch.ignore_accept_header ActionDispatch::Request::Utils.perform_deep_munge = app.config.action_dispatch.perform_deep_munge @@ -88,5 +91,9 @@ class Railtie < Rails::Railtie # :nodoc: ActionDispatch::Http::Cache::Request.strict_freshness = app.config.action_dispatch.strict_freshness ActionDispatch.test_app = app end + + initializer "action_dispatch.backtrace_cleaner" do + ActionDispatch::LogSubscriber.backtrace_cleaner = Rails.backtrace_cleaner + end end end diff --git a/actionpack/test/controller/log_subscriber_test.rb b/actionpack/test/controller/log_subscriber_test.rb index 2c28661ef6d3d..c208d5e5dc0b3 100644 --- a/actionpack/test/controller/log_subscriber_test.rb +++ b/actionpack/test/controller/log_subscriber_test.rb @@ -317,6 +317,22 @@ def test_filter_redirect_bad_uri assert_equal "Redirected to [FILTERED]", logs[1] end + def test_verbose_redirect_logs + old_cleaner = ActionController::LogSubscriber.backtrace_cleaner + ActionController::LogSubscriber.backtrace_cleaner = ActionController::LogSubscriber.backtrace_cleaner.dup + ActionController::LogSubscriber.backtrace_cleaner.add_silencer { |location| !location.include?(__FILE__) } + ActionDispatch.verbose_redirect_logs = true + + get :redirector + wait + + assert_equal 4, logs.size + assert_match(/↳ #{__FILE__}/, logs[2]) + ensure + ActionDispatch.verbose_redirect_logs = false + ActionController::LogSubscriber.backtrace_cleaner = old_cleaner + end + def test_send_data get :data_sender wait diff --git a/actionpack/test/dispatch/routing/log_subscriber_test.rb b/actionpack/test/dispatch/routing/log_subscriber_test.rb index e62153802f974..2c8e0253f6eed 100644 --- a/actionpack/test/dispatch/routing/log_subscriber_test.rb +++ b/actionpack/test/dispatch/routing/log_subscriber_test.rb @@ -25,6 +25,26 @@ def setup assert_match(/Completed 301/, logs.last) end + test "verbose redirect logs" do + old_cleaner = ActionDispatch::LogSubscriber.backtrace_cleaner + ActionDispatch::LogSubscriber.backtrace_cleaner = ActionDispatch::LogSubscriber.backtrace_cleaner.dup + ActionDispatch::LogSubscriber.backtrace_cleaner.add_silencer { |location| !location.include?(__FILE__) } + ActionDispatch.verbose_redirect_logs = true + + draw do + get "redirect", to: redirect("/login") + end + + get "/redirect" + wait + + assert_equal 3, logs.size + assert_match(/↳ #{__FILE__}/, logs[1]) + ensure + ActionDispatch.verbose_redirect_logs = false + ActionDispatch::LogSubscriber.backtrace_cleaner = old_cleaner + end + private def draw(&block) self.class.stub_controllers do |routes| @@ -34,6 +54,10 @@ def draw(&block) end end + def get(path, **options) + super(path, **options.merge(headers: { "action_dispatch.routes" => @app.routes })) + end + def logs @logs ||= @logger.logged(:info) end diff --git a/guides/source/configuring.md b/guides/source/configuring.md index f21d733f80de3..43fa2eadc9fae 100644 --- a/guides/source/configuring.md +++ b/guides/source/configuring.md @@ -2329,6 +2329,10 @@ If set to `true`, cookies will be written even if this criteria is not met. This defaults to `true` in `development`, and `false` in all other environments. +#### `config.action_dispatch.verbose_redirect_logs` + +Specifies if source locations of redirects should be logged below relevant log lines. By default, the flag is `true` in development and `false` in all other environments. + #### `ActionDispatch::Callbacks.before` Takes a block of code to run before the request. diff --git a/guides/source/debugging_rails_applications.md b/guides/source/debugging_rails_applications.md index be7a203913e36..65cacb0aa0baa 100644 --- a/guides/source/debugging_rails_applications.md +++ b/guides/source/debugging_rails_applications.md @@ -250,6 +250,23 @@ ActiveJob.verbose_enqueue_logs = true WARNING: We recommend against using this setting in production environments. +### Verbose redirect logs + +Similar to other verbose log settings above, this logs the source location of a redirect. + +``` +Redirected to http://localhost:3000/posts/1 +↳ app/controllers/posts_controller.rb:32:in `block (2 levels) in create' +``` + +It is enabled by default in development. To enable in other environments, use this configuration: + +```rb +config.action_dispatch.verbose_redirect_logs = true +``` + +As with other verbose loggers, it is not recommended to be used in production environments. + SQL Query Comments ------------------ diff --git a/railties/lib/rails/generators/rails/app/templates/config/environments/development.rb.tt b/railties/lib/rails/generators/rails/app/templates/config/environments/development.rb.tt index 7a13395d9a285..ed7c67bc77fda 100644 --- a/railties/lib/rails/generators/rails/app/templates/config/environments/development.rb.tt +++ b/railties/lib/rails/generators/rails/app/templates/config/environments/development.rb.tt @@ -65,6 +65,9 @@ Rails.application.configure do config.active_job.verbose_enqueue_logs = true <%- end -%> + # Highlight code that triggered redirect in logs. + config.action_dispatch.verbose_redirect_logs = true + <%- unless options[:skip_asset_pipeline] -%> # Suppress logger output for asset requests. config.assets.quiet = true diff --git a/railties/test/application/configuration_test.rb b/railties/test/application/configuration_test.rb index a3d7df2704396..cdc14bba68b4d 100644 --- a/railties/test/application/configuration_test.rb +++ b/railties/test/application/configuration_test.rb @@ -3642,6 +3642,19 @@ def index assert_equal true, ActionDispatch::Http::Cache::Request.strict_freshness end + test "config.action_dispatch.verbose_redirect_logs is true in development" do + build_app + app "development" + + assert ActionDispatch.verbose_redirect_logs + end + + test "config.action_dispatch.verbose_redirect_logs is false in production" do + build_app + app "production" + + assert_not ActionDispatch.verbose_redirect_logs + end test "Rails.application.config.action_mailer.smtp_settings have open_timeout and read_timeout defined as 5 in 7.0 defaults" do remove_from_config '.*config\.load_defaults.*\n' From 26d54e979b9d9df7b7ddc68843f6edf4f1ed5822 Mon Sep 17 00:00:00 2001 From: Adam Maas Date: Thu, 25 Sep 2025 12:45:39 -0400 Subject: [PATCH 0704/1075] Add replicas to test database parallelization setup Setup and configuration of databases for parallel testing now includes replicas. This fixes an issue when using a replica database, database selector middleware, and non-transactional tests, where integration tests running in parallel would select the base test database, i.e. db_test, instead of the numbered parallel worker database, i.e. db_test_{n}. --- activerecord/CHANGELOG.md | 11 +++++++ .../lib/active_record/test_databases.rb | 6 ++-- .../test/cases/test_databases_test.rb | 30 +++++++++++++++++++ 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/activerecord/CHANGELOG.md b/activerecord/CHANGELOG.md index 9a4e4e7d6f059..f47a85e0bb953 100644 --- a/activerecord/CHANGELOG.md +++ b/activerecord/CHANGELOG.md @@ -1,3 +1,14 @@ +* Add replicas to test database parallelization setup. + + Setup and configuration of databases for parallel testing now includes replicas. + + This fixes an issue when using a replica database, database selector middleware, + and non-transactional tests, where integration tests running in parallel would select + the base test database, i.e. `db_test`, instead of the numbered parallel worker database, + i.e. `db_test_{n}`. + + *Adam Maas* + * Optimize schema dumping to prevent duplicate file generation. `ActiveRecord::Tasks::DatabaseTasks.dump_all` now tracks which schema files diff --git a/activerecord/lib/active_record/test_databases.rb b/activerecord/lib/active_record/test_databases.rb index cb3fdb54f0ed2..9ac340b01c7d0 100644 --- a/activerecord/lib/active_record/test_databases.rb +++ b/activerecord/lib/active_record/test_databases.rb @@ -19,10 +19,12 @@ module TestDatabases # :nodoc: def self.create_and_load_schema(i, env_name:) old, ENV["VERBOSE"] = ENV["VERBOSE"], "false" - ActiveRecord::Base.configurations.configs_for(env_name: env_name).each do |db_config| + ActiveRecord::Base.configurations.configs_for(env_name: env_name, include_hidden: true).each do |db_config| db_config._database = "#{db_config.database}_#{i}" - ActiveRecord::Tasks::DatabaseTasks.reconstruct_from_schema(db_config, nil) + if db_config.database_tasks? + ActiveRecord::Tasks::DatabaseTasks.reconstruct_from_schema(db_config, nil) + end end ensure ActiveRecord::Base.establish_connection diff --git a/activerecord/test/cases/test_databases_test.rb b/activerecord/test/cases/test_databases_test.rb index 680512207bd67..d2e6568f47657 100644 --- a/activerecord/test/cases/test_databases_test.rb +++ b/activerecord/test/cases/test_databases_test.rb @@ -101,5 +101,35 @@ def test_order_of_configurations_isnt_changed_by_test_databases ActiveRecord::Base.establish_connection(:arunit) ENV["RAILS_ENV"] = previous_env end + + def test_create_databases_after_fork_with_replica + previous_env, ENV["RAILS_ENV"] = ENV["RAILS_ENV"], "arunit" + prev_configs, ActiveRecord::Base.configurations = ActiveRecord::Base.configurations, { + "arunit" => { + "primary" => { "adapter" => "sqlite3", "database" => "test/db/primary.sqlite3" }, + "replica" => { "adapter" => "sqlite3", "database" => "test/db/primary.sqlite3", "replica" => true } + } + } + + idx = 42 + primary_db_config = ActiveRecord::Base.configurations.configs_for(env_name: "arunit", name: "primary") + expected_primary_database = "#{primary_db_config.database}_#{idx}" + replica_db_config = ActiveRecord::Base.configurations.configs_for(env_name: "arunit", name: "replica", include_hidden: true) + expected_replica_database = "#{replica_db_config.database}_#{idx}" + + ActiveRecord::Tasks::DatabaseTasks.stub(:reconstruct_from_schema, ->(db_config, _) { + assert_equal expected_primary_database, db_config.database + }) do + ActiveSupport::Testing::Parallelization.after_fork_hooks.each { |cb| cb.call(idx) } + end + + # Updates the database configuration + assert_equal expected_primary_database, ActiveRecord::Base.configurations.configs_for(env_name: "arunit", name: "primary").database + assert_equal expected_replica_database, ActiveRecord::Base.configurations.configs_for(env_name: "arunit", name: "replica", include_hidden: true).database + ensure + ActiveRecord::Base.configurations = prev_configs + ActiveRecord::Base.establish_connection(:arunit) + ENV["RAILS_ENV"] = previous_env + end end end From c2b5be9a72dc314df26275926e0e7c89ea0c17f7 Mon Sep 17 00:00:00 2001 From: Yasuo Honda Date: Wed, 11 Jun 2025 14:47:02 +0900 Subject: [PATCH 0705/1075] Support virtual (not persisted) generated columns on PostgreSQL 18+ PostgreSQL 18 Beta 1 introduces virtual (not persisted) generated columns, which are now the default unless the STORED option is explicitly specified. Rails previously supported only stored generated columns on PostgreSQL 12+, via https://github.com/rails/rails/pull/41856, as only stored generated columns were supported by PostgreSQL at the time. This commit adds support for virtual generated columns on PostgreSQL 18 and later: - `t.virtual ... stored: false` now generates virtual generated columns. - Omitting `stored:` also creates virtual generated columns on PostgreSQL 18+. - On PostgreSQL < 18, specifying `stored: false` or omitting `stored:` raises ArgumentError. consistent with the existing behavior in earlier Rails versions. Reference: PostgreSQL 18 release notes https://www.postgresql.org/docs/18/release-18.html > Allow generated columns to be virtual, and make them the default > (Peter Eisentraut, Jian He, Richard Guo, Dean Rasheed) > > Virtual generated columns generate their values when the columns are read, not written. > The write behavior can still be specified via the STORED option. Source: - https://git.postgresql.org/gitweb/?p=postgresql.git;a=commitdiff;h=83ea6c540 - https://git.postgresql.org/gitweb/?p=postgresql.git;a=commitdiff;h=cdc168ad4 - https://git.postgresql.org/gitweb/?p=postgresql.git;a=commitdiff;h=1e4351af3 Co-authored-by: fatkodima Co-authored-by: Ryuta Kamizono --- activerecord/CHANGELOG.md | 15 ++++++ .../connection_adapters/postgresql/column.rb | 4 ++ .../postgresql/schema_creation.rb | 12 +++-- .../postgresql/schema_dumper.rb | 2 +- .../postgresql/virtual_column_test.rb | 50 ++++++++++++++++--- 5 files changed, 70 insertions(+), 13 deletions(-) diff --git a/activerecord/CHANGELOG.md b/activerecord/CHANGELOG.md index 9a4e4e7d6f059..e8c5c1b215464 100644 --- a/activerecord/CHANGELOG.md +++ b/activerecord/CHANGELOG.md @@ -1,3 +1,18 @@ +* Support virtual (not persisted) generated columns on PostgreSQL 18+ + + PostgreSQL 18 introduces virtual (not persisted) generated columns, + which are now the default unless the `stored: true` option is explicitly specified on PostgreSQL 18+. + + ```ruby + create_table :users do |t| + t.string :name + t.virtual :lower_name, type: :string, as: "LOWER(name)", stored: false + t.virtual :name_length, type: :integer, as: "LENGTH(name)" + end + ``` + + *Yasuo Honda* + * Optimize schema dumping to prevent duplicate file generation. `ActiveRecord::Tasks::DatabaseTasks.dump_all` now tracks which schema files diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/column.rb b/activerecord/lib/active_record/connection_adapters/postgresql/column.rb index 926ab09d905ac..d6a0cc195649e 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/column.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/column.rb @@ -30,6 +30,10 @@ def virtual? @generated.present? end + def virtual_stored? + @generated == "s" + end + def has_default? super && !virtual? end 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 c17a7af056eeb..c8d28214ca0b9 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/schema_creation.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/schema_creation.rb @@ -6,6 +6,7 @@ module PostgreSQL class SchemaCreation < SchemaCreation # :nodoc: private delegate :quoted_include_columns_for_index, to: :@conn + delegate :database_version, to: :@conn def visit_AlterTable(o) sql = super @@ -126,16 +127,17 @@ def add_column_options!(sql, options) end if as = options[:as] - sql << " GENERATED ALWAYS AS (#{as})" + stored = options[:stored] - if options[:stored] - sql << " STORED" - else + if stored != true && database_version < 18_00_00 raise ArgumentError, <<~MSG - PostgreSQL currently does not support VIRTUAL (not persisted) generated columns. + PostgreSQL versions before 18 do not support VIRTUAL (not persisted) generated columns. Specify 'stored: true' option for '#{options[:column].name}' MSG end + + sql << " GENERATED ALWAYS AS (#{as})" + sql << (stored ? " STORED" : " VIRTUAL") end super 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 9481637b1d228..fdb4b8fcaf3b8 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/schema_dumper.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/schema_dumper.rb @@ -103,7 +103,7 @@ def prepare_column_options(column) if @connection.supports_virtual_columns? && column.virtual? spec[:as] = extract_expression_for_virtual_column(column) - spec[:stored] = true + spec[:stored] = "true" if column.virtual_stored? spec = { type: schema_type(column).inspect }.merge!(spec) end diff --git a/activerecord/test/cases/adapters/postgresql/virtual_column_test.rb b/activerecord/test/cases/adapters/postgresql/virtual_column_test.rb index 8938aba2cf0f3..81a6ff549f608 100644 --- a/activerecord/test/cases/adapters/postgresql/virtual_column_test.rb +++ b/activerecord/test/cases/adapters/postgresql/virtual_column_test.rb @@ -19,7 +19,12 @@ def setup t.virtual :name_octet_length, type: :integer, as: "OCTET_LENGTH(name)", stored: true t.integer :column1 t.virtual :column2, type: :integer, as: "column1 + 1", stored: true + if @connection.database_version >= 18_00_00 + t.virtual :column3, type: :integer, as: "column1 + 2", stored: false + t.virtual :column4, type: :integer, as: "column1 + 3" + end end + VirtualColumn.create(name: "Rails") end @@ -47,6 +52,7 @@ def test_virtual_column def test_stored_column column = VirtualColumn.columns_hash["name_length"] assert_predicate column, :virtual? + assert_predicate column, :virtual_stored? if @connection.database_version >= 18_00_00 assert_equal 5, VirtualColumn.take.name_length end @@ -57,18 +63,44 @@ def test_change_table VirtualColumn.reset_column_information column = VirtualColumn.columns_hash["lower_name"] assert_predicate column, :virtual? + assert_predicate column, :virtual_stored? if @connection.database_version >= 18_00_00 assert_equal "rails", VirtualColumn.take.lower_name end - def test_non_persisted_column - message = <<~MSG - PostgreSQL currently does not support VIRTUAL (not persisted) generated columns. - Specify 'stored: true' option for 'invalid_definition' - MSG + if ActiveRecord::Base.lease_connection.database_version >= 18_00_00 + def test_change_table_as_stored_false + @connection.change_table :virtual_columns do |t| + t.virtual :reversed_name, type: :string, as: "REVERSE(name)", stored: false + end + VirtualColumn.reset_column_information + column = VirtualColumn.columns_hash["reversed_name"] + assert_predicate column, :virtual? + assert_not_predicate column, :virtual_stored? + assert_equal "sliaR", VirtualColumn.take.reversed_name + end - assert_raise ArgumentError, message do + def test_change_table_without_stored_option @connection.change_table :virtual_columns do |t| - t.virtual :invalid_definition, type: :string, as: "LOWER(name)" + t.virtual :ascii_name, type: :string, as: "ASCII(name)" + end + VirtualColumn.reset_column_information + column = VirtualColumn.columns_hash["ascii_name"] + assert_predicate column, :virtual? + assert_not_predicate column, :virtual_stored? + assert_equal "82", VirtualColumn.take.ascii_name + end + + else + def test_non_persisted_column + message = <<~MSG + PostgreSQL versions before 18 do not support VIRTUAL (not persisted) generated columns. + Specify 'stored: true' option for 'invalid_definition' + MSG + + assert_raise ArgumentError, match: message do + @connection.change_table :virtual_columns do |t| + t.virtual :invalid_definition, type: :string, as: "LOWER(name)" + end end end end @@ -79,6 +111,10 @@ def test_schema_dumping assert_match(/t\.virtual\s+"name_length",\s+type: :integer,\s+as: "length\(\(name\)::text\)", stored: true$/i, output) assert_match(/t\.virtual\s+"name_octet_length",\s+type: :integer,\s+as: "octet_length\(\(name\)::text\)", stored: true$/i, output) assert_match(/t\.virtual\s+"column2",\s+type: :integer,\s+as: "\(column1 \+ 1\)", stored: true$/i, output) + if @connection.database_version >= 18_00_00 + assert_match(/t\.virtual\s+"column3",\s+type: :integer,\s+as: "\(column1 \+ 2\)"$/i, output) + assert_match(/t\.virtual\s+"column4",\s+type: :integer,\s+as: "\(column1 \+ 3\)"$/i, output) + end end def test_build_fixture_sql From 176c42ff58d8c65c5aab1d4a0bdd202fc50af1d4 Mon Sep 17 00:00:00 2001 From: Ian Terrell Date: Fri, 26 Sep 2025 12:36:55 -0400 Subject: [PATCH 0706/1075] Prefer changed_for_autosave? [Fix #55771] - The original code intends to avoid already persisted invalid records from preventing new associated records from being saved. - Issue #55771 documents that this could lead to previously valid data being saved and becoming invalid. - The code originated in https://github.com/rails/rails/pull/53951 - And was updated in https://github.com/rails/rails/pull/54273 - The solution suggested by @axlekb in https://github.com/rails/rails/pull/53951#pullrequestreview-3272124308 --- .../lib/active_record/autosave_association.rb | 2 +- .../test/cases/autosave_association_test.rb | 55 +++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/activerecord/lib/active_record/autosave_association.rb b/activerecord/lib/active_record/autosave_association.rb index 5e350f8e4e32b..e20c4a652a0db 100644 --- a/activerecord/lib/active_record/autosave_association.rb +++ b/activerecord/lib/active_record/autosave_association.rb @@ -374,7 +374,7 @@ def association_valid?(association, record) context = validation_context if custom_validation_context? return true if record.valid?(context) - if record.changed? || record.new_record? || context + if context || record.changed_for_autosave? associated_errors = record.errors.objects else # If there are existing invalid records in the DB, we should still be able to reference them. diff --git a/activerecord/test/cases/autosave_association_test.rb b/activerecord/test/cases/autosave_association_test.rb index cf157e1e1e796..e32c73b759bab 100644 --- a/activerecord/test/cases/autosave_association_test.rb +++ b/activerecord/test/cases/autosave_association_test.rb @@ -2443,3 +2443,58 @@ def test_should_not_raise_error end end end + +class TestAutosaveAssociationWithNestedAttributes < ActiveRecord::TestCase + class Part < ActiveRecord::Base + self.table_name = "ship_parts" + end + + class Ship < ActiveRecord::Base + self.table_name = "ships" + has_many :parts, class_name: Part.name + accepts_nested_attributes_for :parts, allow_destroy: true + + validate :has_at_least_two_parts + def has_at_least_two_parts + current_parts = parts.select { |p| !p.marked_for_destruction? } + errors.add(:parts, "must have at least two parts") if current_parts.size < 2 + end + end + + class Pirate < ActiveRecord::Base + self.table_name = "pirates" + has_many :ships, class_name: Ship.name + accepts_nested_attributes_for :ships, allow_destroy: true + end + + def test_should_be_invalid_when_nested_attributes_deletion_breaks_validation + pirate = Pirate.create! + ship = pirate.ships.new + 2.times do |i| + ship.parts.build + end + part = ship.parts.first + ship.save! + + deletion_params = { + "ships_attributes" => { + "0" => { + "id" => ship.id, + "parts_attributes" => { + "0" => { + "id" => part.id, + "_destroy" => "1", + }, + } + } + } + } + + assert_not pirate.update(deletion_params) + assert_nothing_raised do + part.reload + end + assert_includes pirate.errors[:"ships.parts"], "must have at least two parts" + assert_includes ship.errors[:parts], "must have at least two parts" + end +end From 8f56770baa8f259785c1a068dd94042e24d26038 Mon Sep 17 00:00:00 2001 From: Edouard CHIN Date: Sat, 27 Sep 2025 01:50:49 +0200 Subject: [PATCH 0707/1075] Remove the scroll overflow on code blocks: - We introduced a change in #54661 to prevent overflowing codeblocks into other elements. This change creates white scroll bars on MacOS (maybe other platfroms too) only when you connect a mouse, it's a behaviour of MacOS that it displays scroll bar on elements. This commit removes the overflow property without affecting overflow problem, that is because the PR linked above restrained the element to a 100% width. --- guides/assets/stylesrc/components/_code-container.scss | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/guides/assets/stylesrc/components/_code-container.scss b/guides/assets/stylesrc/components/_code-container.scss index ce783209e2b94..47d517d49320b 100644 --- a/guides/assets/stylesrc/components/_code-container.scss +++ b/guides/assets/stylesrc/components/_code-container.scss @@ -2,7 +2,7 @@ // Containers // // These are interstitial elements used throughout the guides, providing help, -// context, more info, or warnings to readers. +// context, more info, or warnings to readers. // ---------------------------------------------------------------------------- /* Same bottom margin for special boxes than for regular paragraphs, this way @@ -49,7 +49,6 @@ dl dd .interstitial { pre { margin: 0; - overflow: scroll; white-space: pre; } From 0e8dd647784a7844fee21e839ab0a8a2ed87ceb1 Mon Sep 17 00:00:00 2001 From: Yasuo Honda Date: Fri, 26 Sep 2025 16:57:05 +0900 Subject: [PATCH 0708/1075] Remove unnecessary comment lines "HELPER METHODS" This comment was added via https://github.com/rails/rails/commit/5766539342426e956980bf6f54ef99600cbfc33e , at that time there were other methods as "HELPER METHODS", but now there is only one method `error_number`. This "HELPER METHODS" comment appears at https://edgeapi.rubyonrails.org/classes/ActiveRecord/ConnectionAdapters/Mysql2Adapter.html#method-i-error_number That does not help users to understand the method. Fix #55754 --- .../active_record/connection_adapters/abstract_mysql_adapter.rb | 2 -- .../lib/active_record/connection_adapters/mysql2_adapter.rb | 2 -- 2 files changed, 4 deletions(-) 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 843b04589a078..59936e12bc5a6 100644 --- a/activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb @@ -209,8 +209,6 @@ def index_algorithms } end - # HELPER METHODS =========================================== - # Must return the MySQL error number from the exception, if the exception has an # error number. def error_number(exception) # :nodoc: diff --git a/activerecord/lib/active_record/connection_adapters/mysql2_adapter.rb b/activerecord/lib/active_record/connection_adapters/mysql2_adapter.rb index 2bce74ca52b68..e3d4a87843dd5 100644 --- a/activerecord/lib/active_record/connection_adapters/mysql2_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/mysql2_adapter.rb @@ -92,8 +92,6 @@ def supports_lazy_transactions? true end - # HELPER METHODS =========================================== - def error_number(exception) exception.error_number if exception.respond_to?(:error_number) end From fe379af010bf2e2473f43949083353ce8cb00a26 Mon Sep 17 00:00:00 2001 From: Yasuo Honda Date: Thu, 25 Sep 2025 14:42:23 +0900 Subject: [PATCH 0709/1075] Bump PostgreSQL client version to 18 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit bumps the PostgreSQL client version to 18 in the devcontainer since the `postgres:latest` image now uses PostgreSQL 18. https://github.com/docker-library/postgres/commit/22ca5c8d8e4b37bece4d38dbce1a060583b5308a - With this fix ```sql vscode ➜ /workspaces/rails/activerecord (pg18-client-devcontainer) $ psql -d postgres psql (18.0 (Debian 18.0-1.pgdg12+3)) Type "help" for help. postgres=# select version(); version -------------------------------------------------------------------------------------------------------------------------- PostgreSQL 18.0 (Debian 18.0-1.pgdg13+3) on aarch64-unknown-linux-gnu, compiled by gcc (Debian 14.2.0-19) 14.2.0, 64-bit (1 row) postgres=# ``` --- .devcontainer/devcontainer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index fdb8703a3274c..302b4c600027d 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -14,7 +14,7 @@ "version": "latest" }, "ghcr.io/rails/devcontainer/features/postgres-client:1.1.3": { - "version": "17" + "version": "18" } }, From f2024b09d713fde3cd23e2b08dadd719afa98c88 Mon Sep 17 00:00:00 2001 From: Joshua Young Date: Sat, 27 Sep 2025 16:26:38 +1000 Subject: [PATCH 0710/1075] [Fix #55776] `class_attribute` on instance singleton class raises `NameError` --- activesupport/CHANGELOG.md | 13 +++++++++++ .../core_ext/class/attribute.rb | 14 +++++++----- .../test/core_ext/class/attribute_test.rb | 22 ++++++++++++++++++- 3 files changed, 42 insertions(+), 7 deletions(-) diff --git a/activesupport/CHANGELOG.md b/activesupport/CHANGELOG.md index b809328401411..ba53c7ade0afb 100644 --- a/activesupport/CHANGELOG.md +++ b/activesupport/CHANGELOG.md @@ -1,3 +1,16 @@ +* Fix `NameError` when `class_attribute` is defined on instance singleton classes. + + Previously, calling `class_attribute` on an instance's singleton class would raise + a `NameError` when accessing the attribute through the instance. + + ```ruby + object = MyClass.new + object.singleton_class.class_attribute :foo, default: "bar" + object.foo # previously raised NameError, now returns "bar" + ``` + + *Joshua Young* + * Introduce `ActiveSupport::Testing::EventReporterAssertions#with_debug_event_reporting` to enable event reporter debug mode in tests. diff --git a/activesupport/lib/active_support/core_ext/class/attribute.rb b/activesupport/lib/active_support/core_ext/class/attribute.rb index 57264dfe25c33..e52bf99fbfda1 100644 --- a/activesupport/lib/active_support/core_ext/class/attribute.rb +++ b/activesupport/lib/active_support/core_ext/class/attribute.rb @@ -96,14 +96,16 @@ def class_attribute(*attrs, instance_accessor: true, namespaced_name = :"__class_attr_#{name}" ::ActiveSupport::ClassAttribute.redefine(self, name, namespaced_name, default) - delegators = [ - "def #{name}; #{namespaced_name}; end", - "def #{name}=(value); self.#{namespaced_name} = value; end", - ] + class_methods << "def #{name}; #{namespaced_name}; end" + class_methods << "def #{name}=(value); self.#{namespaced_name} = value; end" - class_methods.concat(delegators) if singleton_class? - methods.concat(delegators) + methods << <<~RUBY if instance_reader + silence_redefinition_of_method(:#{name}) + def #{name} + self.singleton_class.#{name} + end + RUBY else methods << <<~RUBY if instance_reader silence_redefinition_of_method def #{name} diff --git a/activesupport/test/core_ext/class/attribute_test.rb b/activesupport/test/core_ext/class/attribute_test.rb index a185e61f5061d..dc881c0e365d6 100644 --- a/activesupport/test/core_ext/class/attribute_test.rb +++ b/activesupport/test/core_ext/class/attribute_test.rb @@ -116,7 +116,7 @@ def setup assert_equal "plop", @klass.setting end - test "when defined in a class's singleton" do + test "when defined in a class's singleton class" do @klass = Class.new do class << self class_attribute :__callbacks, default: 1 @@ -130,6 +130,26 @@ class << self @klass.__callbacks = 4 assert_equal 1, @klass.__callbacks assert_equal 1, @klass.singleton_class.__callbacks + + @klass.singleton_class.__callbacks = 4 + assert_equal 4, @klass.__callbacks + assert_equal 4, @klass.singleton_class.__callbacks + end + + test "when defined on an instance's singleton class" do + object = @klass.new + + object.singleton_class.class_attribute :external_attr, default: "default_value" + assert_equal "default_value", object.external_attr + assert_equal "default_value", object.singleton_class.external_attr + + object.external_attr = "new_value" + assert_equal "default_value", object.external_attr + assert_equal "default_value", object.singleton_class.external_attr + + object.singleton_class.external_attr = "another_value" + assert_equal "another_value", object.external_attr + assert_equal "another_value", object.singleton_class.external_attr end test "works well with module singleton classes" do From 808ca69b7e9d973c46541a9176fded00560ca729 Mon Sep 17 00:00:00 2001 From: Yuji Yaginuma Date: Sat, 27 Sep 2025 15:59:45 +0900 Subject: [PATCH 0711/1075] Don't output deprecated message in newly generated applications Since #55496, the depreciation message about `raise_on_open _redirects` is shown in newly generated applications. This is because the deprecations is shown when `raise_on_open_redirects` is not null, but the default value of it is `false`. https://github.com/rails/rails/blob/ec4337aae7d358e9d8ae3fc539347a2618b8fbe0/actionpack/lib/action_controller/railtie.rb#L110 https://github.com/rails/rails/blob/ec4337aae7d358e9d8ae3fc539347a2618b8fbe0/actionpack/lib/action_controller/railtie.rb#L15 In addition that, we set the value in `load_defaults`. https://github.com/rails/rails/blob/ec4337aae7d358e9d8ae3fc539347a2618b8fbe0/railties/lib/rails/application/configuration.rb#L270 This PR removed config key and checked the key existence for the condition of deprecation message. Fixes #55772. --- actionpack/lib/action_controller/railtie.rb | 8 ++++++-- guides/source/configuring.md | 8 ++------ .../lib/rails/application/configuration.rb | 6 +----- .../test/application/configuration_test.rb | 19 +++++++++++++------ 4 files changed, 22 insertions(+), 19 deletions(-) diff --git a/actionpack/lib/action_controller/railtie.rb b/actionpack/lib/action_controller/railtie.rb index c2695aea54820..f5022eb0ec9fc 100644 --- a/actionpack/lib/action_controller/railtie.rb +++ b/actionpack/lib/action_controller/railtie.rb @@ -12,7 +12,6 @@ module ActionController class Railtie < Rails::Railtie # :nodoc: config.action_controller = ActiveSupport::OrderedOptions.new - config.action_controller.raise_on_open_redirects = false config.action_controller.action_on_open_redirect = :log config.action_controller.action_on_path_relative_redirect = :log config.action_controller.log_query_tags_around_actions = true @@ -107,11 +106,16 @@ class Railtie < Rails::Railtie # :nodoc: initializer "action_controller.open_redirects" do |app| ActiveSupport.on_load(:action_controller, run_once: true) do - if app.config.action_controller.raise_on_open_redirects != nil + if app.config.action_controller.has_key?(:raise_on_open_redirects) ActiveSupport.deprecator.warn(<<~MSG.squish) `raise_on_open_redirects` is deprecated and will be removed in a future Rails version. Use `config.action_controller.action_on_open_redirect = :raise` instead. MSG + + # Fallback to the default behavior in case of `load_default` set `action_on_open_redirect`, but apps set `raise_on_open_redirects`. + if app.config.action_controller.raise_on_open_redirects == false && app.config.action_controller.action_on_open_redirect == :raise + self.action_on_open_redirect = :log + end end end end diff --git a/guides/source/configuring.md b/guides/source/configuring.md index 43fa2eadc9fae..772716d117620 100644 --- a/guides/source/configuring.md +++ b/guides/source/configuring.md @@ -60,7 +60,6 @@ Below are the default values associated with each target version. In cases of co #### Default Values for Target Version 8.1 -- [`config.action_controller.action_on_open_redirect`](#config-action-controller-action-on-open-redirect): `:raise` - [`config.action_controller.action_on_path_relative_redirect`](#config-action-controller-action-on-path-relative-redirect): `:raise` - [`config.action_controller.escape_json_responses`](#config-action-controller-escape-json-responses): `false` - [`config.action_view.remove_hidden_field_autocomplete`](#config-action-view-remove-hidden-field-autocomplete): `true` @@ -110,7 +109,7 @@ Below are the default values associated with each target version. In cases of co #### Default Values for Target Version 7.0 -- [`config.action_controller.raise_on_open_redirects`](#config-action-controller-raise-on-open-redirects): `true` +- [`config.action_controller.action_on_open_redirect`](#config-action-controller-action-on-open-redirect): `:raise` - [`config.action_controller.wrap_parameters_by_default`](#config-action-controller-wrap-parameters-by-default): `true` - [`config.action_dispatch.cookies_serializer`](#config-action-dispatch-cookies-serializer): `:json` - [`config.action_dispatch.default_headers`](#config-action-dispatch-default-headers): `{ "X-Frame-Options" => "SAMEORIGIN", "X-XSS-Protection" => "0", "X-Content-Type-Options" => "nosniff", "X-Download-Options" => "noopen", "X-Permitted-Cross-Domain-Policies" => "none", "Referrer-Policy" => "strict-origin-when-cross-origin" }` @@ -1964,12 +1963,9 @@ with an external host is passed to [redirect_to][]. If an open redirect should be allowed, then `allow_other_host: true` can be added to the call to `redirect_to`. -The default value depends on the `config.load_defaults` target version: - | Starting with version | The default value is | | --------------------- | -------------------- | | (original) | `false` | -| 7.0 | `true` | [redirect_to]: https://api.rubyonrails.org/classes/ActionController/Redirecting.html#method-i-redirect_to @@ -1995,7 +1991,7 @@ The default value depends on the `config.load_defaults` target version: | Starting with version | The default value is | | --------------------- | -------------------- | | (original) | `:log` | -| 8.1 | `:raise` | +| 7.0 | `:raise` | #### `config.action_controller.action_on_path_relative_redirect` diff --git a/railties/lib/rails/application/configuration.rb b/railties/lib/rails/application/configuration.rb index 0b0ee6b107b5f..b87f3e242a86e 100644 --- a/railties/lib/rails/application/configuration.rb +++ b/railties/lib/rails/application/configuration.rb @@ -267,7 +267,7 @@ def load_defaults(target_version) end if respond_to?(:action_controller) - action_controller.raise_on_open_redirects = true + action_controller.action_on_open_redirect = :raise action_controller.wrap_parameters_by_default = true end when "7.1" @@ -351,10 +351,6 @@ def load_defaults(target_version) when "8.1" load_defaults "8.0" - if respond_to?(:action_controller) - action_controller.action_on_open_redirect = :raise - end - # Development and test environments tend to reload code and # redefine methods (e.g. mocking), hence YJIT isn't generally # faster in these environments. diff --git a/railties/test/application/configuration_test.rb b/railties/test/application/configuration_test.rb index cdc14bba68b4d..5b96dca52698f 100644 --- a/railties/test/application/configuration_test.rb +++ b/railties/test/application/configuration_test.rb @@ -1580,12 +1580,6 @@ def index assert_equal "https://foo.example.com:9001/bar/posts", posts_url end - test "ActionController::Base.raise_on_open_redirects is true by default for new apps" do - app "development" - - assert_equal true, ActionController::Base.raise_on_open_redirects - end - test "ActionController::Base.raise_on_open_redirects is false by default for upgraded apps" do remove_from_config '.*config\.load_defaults.*\n' add_to_config 'config.load_defaults "6.1"' @@ -1606,6 +1600,19 @@ def index assert_equal true, ActionController::Base.raise_on_open_redirects end + test "ActionController::Base.action_on_open_redirect is :raise by default for new apps" do + app "development" + + assert_equal :raise, ActionController::Base.action_on_open_redirect + end + + test "ActionController::Base.action_on_open_redirect is :log when raise_on_open_redirects is false" do + add_to_config "config.action_controller.raise_on_open_redirects = false" + app "development" + + assert_equal :log, ActionController::Base.action_on_open_redirect + end + test "config.action_dispatch.show_exceptions is sent in env" do make_basic_app do |application| application.config.action_dispatch.show_exceptions = :all From e6a5b1095f36ab2effa50dea82421b964798aada Mon Sep 17 00:00:00 2001 From: Ryuta Kamizono Date: Mon, 29 Sep 2025 14:21:57 +0900 Subject: [PATCH 0712/1075] Revert "Don't want to duplicate untouched values on `merge`" This reverts commit 31d3caece6be5511dbfb5269f926dcf1648b59ac. --- activerecord/lib/active_record/relation/merger.rb | 2 +- activerecord/test/cases/relation_test.rb | 12 ++++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/activerecord/lib/active_record/relation/merger.rb b/activerecord/lib/active_record/relation/merger.rb index 55ca34b7df59d..d34a04fef8d0c 100644 --- a/activerecord/lib/active_record/relation/merger.rb +++ b/activerecord/lib/active_record/relation/merger.rb @@ -85,7 +85,7 @@ def merge_select_values return if other.select_values.empty? if other.model == relation.model - relation.select_values += other.select_values if relation.select_values != other.select_values + relation.select_values += other.select_values else relation.select_values += other.instance_eval do arel_columns(select_values) diff --git a/activerecord/test/cases/relation_test.rb b/activerecord/test/cases/relation_test.rb index 77ab9a9b5144b..ffdec045633f2 100644 --- a/activerecord/test/cases/relation_test.rb +++ b/activerecord/test/cases/relation_test.rb @@ -47,11 +47,10 @@ def test_multi_values_deduplication_with_merge unscope: [ :where ], extending: [ Module.new ], with: [ foo: Post.all ], - select: [ :id, :id ], } expected.default = [ Object.new ] - Relation::MULTI_VALUE_METHODS.each do |method| + (Relation::MULTI_VALUE_METHODS - [:select]).each do |method| getter, setter = "#{method}_values", "#{method}_values=" values = expected[method] relation = Relation.new(FakeKlass) @@ -294,6 +293,15 @@ def test_relation_merging_with_joins_as_join_dependency_pick_proper_parent assert_equal 3, relation.where(id: post.id).pluck(:id).size end + def test_merge_preserves_duplicate_columns + quoted_posts_id = Regexp.escape(quote_table_name("posts.id")) + quoted_posts = Regexp.escape(quote_table_name("posts")) + posts = Post.select(:id) + assert_queries_match(/SELECT #{quoted_posts_id}, #{quoted_posts_id} FROM #{quoted_posts}/i) do + posts.merge(posts).to_a + end + end + def test_merge_raises_with_invalid_argument assert_raises ArgumentError do relation = Relation.new(FakeKlass) From a71249b631d3392b5f747714463d00250bfa94c1 Mon Sep 17 00:00:00 2001 From: Ryuta Kamizono Date: Mon, 29 Sep 2025 14:22:55 +0900 Subject: [PATCH 0713/1075] Revert "Merge pull request #53723 from fatkodima/preserve-duplicate-selected-columns" This reverts commit 0628f72ab976dabc64b219a38a73c5c06b68d729, reversing changes made to 04a63ad9b6575de8434be6dc39d62eb8cca1ee4c. --- activerecord/lib/active_record/relation/merger.rb | 4 ++-- .../lib/active_record/relation/query_methods.rb | 2 +- activerecord/test/cases/relation/select_test.rb | 8 -------- activerecord/test/cases/relation_test.rb | 11 +---------- 4 files changed, 4 insertions(+), 21 deletions(-) diff --git a/activerecord/lib/active_record/relation/merger.rb b/activerecord/lib/active_record/relation/merger.rb index d34a04fef8d0c..80c10734bcc52 100644 --- a/activerecord/lib/active_record/relation/merger.rb +++ b/activerecord/lib/active_record/relation/merger.rb @@ -85,9 +85,9 @@ def merge_select_values return if other.select_values.empty? if other.model == relation.model - relation.select_values += other.select_values + relation.select_values |= other.select_values else - relation.select_values += other.instance_eval do + relation.select_values |= other.instance_eval do arel_columns(select_values) end end diff --git a/activerecord/lib/active_record/relation/query_methods.rb b/activerecord/lib/active_record/relation/query_methods.rb index c1bfb3970e9c8..7d34f39e842b1 100644 --- a/activerecord/lib/active_record/relation/query_methods.rb +++ b/activerecord/lib/active_record/relation/query_methods.rb @@ -426,7 +426,7 @@ def select(*fields) end def _select!(*fields) # :nodoc: - self.select_values += fields + self.select_values |= fields self end diff --git a/activerecord/test/cases/relation/select_test.rb b/activerecord/test/cases/relation/select_test.rb index 30111618b1b49..784d9ffc97e14 100644 --- a/activerecord/test/cases/relation/select_test.rb +++ b/activerecord/test/cases/relation/select_test.rb @@ -115,14 +115,6 @@ def test_select_with_hash_argument_with_few_tables assert_not_nil post.post_title end - def test_select_preserves_duplicate_columns - quoted_posts_id = Regexp.escape(quote_table_name("posts.id")) - quoted_posts = Regexp.escape(quote_table_name("posts")) - assert_queries_match(/SELECT #{quoted_posts_id}, #{quoted_posts_id} FROM #{quoted_posts}/i) do - Post.select(:id, :id).to_a - end - end - def test_reselect expected = Post.select(:title).to_sql assert_equal expected, Post.select(:title, :body).reselect(:title).to_sql diff --git a/activerecord/test/cases/relation_test.rb b/activerecord/test/cases/relation_test.rb index ffdec045633f2..f88e5021601e5 100644 --- a/activerecord/test/cases/relation_test.rb +++ b/activerecord/test/cases/relation_test.rb @@ -50,7 +50,7 @@ def test_multi_values_deduplication_with_merge } expected.default = [ Object.new ] - (Relation::MULTI_VALUE_METHODS - [:select]).each do |method| + Relation::MULTI_VALUE_METHODS.each do |method| getter, setter = "#{method}_values", "#{method}_values=" values = expected[method] relation = Relation.new(FakeKlass) @@ -293,15 +293,6 @@ def test_relation_merging_with_joins_as_join_dependency_pick_proper_parent assert_equal 3, relation.where(id: post.id).pluck(:id).size end - def test_merge_preserves_duplicate_columns - quoted_posts_id = Regexp.escape(quote_table_name("posts.id")) - quoted_posts = Regexp.escape(quote_table_name("posts")) - posts = Post.select(:id) - assert_queries_match(/SELECT #{quoted_posts_id}, #{quoted_posts_id} FROM #{quoted_posts}/i) do - posts.merge(posts).to_a - end - end - def test_merge_raises_with_invalid_argument assert_raises ArgumentError do relation = Relation.new(FakeKlass) From 943a31c500c62e3f81d7997a7586fb9bd1b5cd5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89tienne=20Barri=C3=A9?= Date: Mon, 29 Sep 2025 11:52:38 +0200 Subject: [PATCH 0714/1075] Stop escaping JS separators in JSON by default Introduce a new framework default to skip escaping LINE SEPARATOR (U+2028) and PARAGRAPH SEPARATOR (U+2029) in JSON. Co-authored-by: Jean Boussier --- activesupport/CHANGELOG.md | 9 +++++ .../lib/active_support/json/encoding.rb | 40 ++++++++++++------- activesupport/test/json/encoding_test.rb | 4 ++ guides/source/configuring.md | 15 +++++++ .../lib/rails/application/configuration.rb | 4 ++ .../new_framework_defaults_8_1.rb.tt | 8 ++++ 6 files changed, 66 insertions(+), 14 deletions(-) diff --git a/activesupport/CHANGELOG.md b/activesupport/CHANGELOG.md index ba53c7ade0afb..5f9f700cd10a8 100644 --- a/activesupport/CHANGELOG.md +++ b/activesupport/CHANGELOG.md @@ -1,3 +1,12 @@ +* Add `config.active_support.escape_js_separators_in_json`. + + Introduce a new framework default to skip escaping LINE SEPARATOR (U+2028) and PARAGRAPH SEPARATOR (U+2029) in JSON. + + Historically these characters were not valid inside JavaScript literal strings but that changed in ECMAScript 2019. + As such it's no longer a concern in modern browsers: https://caniuse.com/mdn-javascript_builtins_json_json_superset. + + *Étienne Barrié*, *Jean Boussier* + * Fix `NameError` when `class_attribute` is defined on instance singleton classes. Previously, calling `class_attribute` on an instance's singleton class would raise diff --git a/activesupport/lib/active_support/json/encoding.rb b/activesupport/lib/active_support/json/encoding.rb index 6d7c31349384b..7f1529b408a4e 100644 --- a/activesupport/lib/active_support/json/encoding.rb +++ b/activesupport/lib/active_support/json/encoding.rb @@ -8,6 +8,7 @@ class << self delegate :use_standard_json_time_format, :use_standard_json_time_format=, :time_precision, :time_precision=, :escape_html_entities_in_json, :escape_html_entities_in_json=, + :escape_js_separators_in_json, :escape_js_separators_in_json=, :json_encoder, :json_encoder=, to: :'ActiveSupport::JSON::Encoding' end @@ -67,8 +68,9 @@ module Encoding # :nodoc: "&".b => '\u0026'.b, } - ESCAPE_REGEX_WITH_HTML_ENTITIES = Regexp.union(*ESCAPED_CHARS.keys) - ESCAPE_REGEX_WITHOUT_HTML_ENTITIES = Regexp.union(U2028, U2029) + HTML_ENTITIES_REGEX = Regexp.union(*(ESCAPED_CHARS.keys - [U2028, U2029])) + FULL_ESCAPE_REGEX = Regexp.union(*ESCAPED_CHARS.keys) + JS_SEPARATORS_REGEX = Regexp.union(U2028, U2029) class JSONGemEncoder # :nodoc: attr_reader :options @@ -86,14 +88,15 @@ def encode(value) return json unless @options.fetch(:escape, true) - # Rails does more escaping than the JSON gem natively does (we - # escape \u2028 and \u2029 and optionally >, <, & to work around - # certain browser problems). json.force_encoding(::Encoding::BINARY) if @options.fetch(:escape_html_entities, Encoding.escape_html_entities_in_json) - json.gsub!(ESCAPE_REGEX_WITH_HTML_ENTITIES, ESCAPED_CHARS) - else - json.gsub!(ESCAPE_REGEX_WITHOUT_HTML_ENTITIES, ESCAPED_CHARS) + if Encoding.escape_js_separators_in_json + json.gsub!(FULL_ESCAPE_REGEX, ESCAPED_CHARS) + else + json.gsub!(HTML_ENTITIES_REGEX, ESCAPED_CHARS) + end + elsif Encoding.escape_js_separators_in_json + json.gsub!(JS_SEPARATORS_REGEX, ESCAPED_CHARS) end json.force_encoding(::Encoding::UTF_8) end @@ -184,14 +187,15 @@ def encode(value) return json unless @escape - # Rails does more escaping than the JSON gem natively does (we - # escape \u2028 and \u2029 and optionally >, <, & to work around - # certain browser problems). json.force_encoding(::Encoding::BINARY) if @options.fetch(:escape_html_entities, Encoding.escape_html_entities_in_json) - json.gsub!(ESCAPE_REGEX_WITH_HTML_ENTITIES, ESCAPED_CHARS) - else - json.gsub!(ESCAPE_REGEX_WITHOUT_HTML_ENTITIES, ESCAPED_CHARS) + if Encoding.escape_js_separators_in_json + json.gsub!(FULL_ESCAPE_REGEX, ESCAPED_CHARS) + else + json.gsub!(HTML_ENTITIES_REGEX, ESCAPED_CHARS) + end + elsif Encoding.escape_js_separators_in_json + json.gsub!(JS_SEPARATORS_REGEX, ESCAPED_CHARS) end json.force_encoding(::Encoding::UTF_8) end @@ -207,6 +211,13 @@ class << self # as a safety measure. attr_accessor :escape_html_entities_in_json + # If true, encode LINE SEPARATOR (U+2028) and PARAGRAPH SEPARATOR (U+2029) + # as escaped unicode sequences ('\u2028' and '\u2029'). + # Historically these characters were not valid inside JavaScript strings + # but that changed in ECMAScript 2019. As such it's no longer a concern in + # modern browsers: https://caniuse.com/mdn-javascript_builtins_json_json_superset. + attr_accessor :escape_js_separators_in_json + # Sets the precision of encoded time values. # Defaults to 3 (equivalent to millisecond precision) attr_accessor :time_precision @@ -232,6 +243,7 @@ def encode_without_escape(value) # :nodoc: self.use_standard_json_time_format = true self.escape_html_entities_in_json = true + self.escape_js_separators_in_json = true self.json_encoder = if defined?(JSONGemCoderEncoder) JSONGemCoderEncoder diff --git a/activesupport/test/json/encoding_test.rb b/activesupport/test/json/encoding_test.rb index ae21fc089c6ac..757be9952f63d 100644 --- a/activesupport/test/json/encoding_test.rb +++ b/activesupport/test/json/encoding_test.rb @@ -3,6 +3,7 @@ require "securerandom" require_relative "../abstract_unit" require "active_support/core_ext/string/inflections" +require "active_support/core_ext/object/with" require "active_support/json" require "active_support/time" require_relative "../time_zone_test_helpers" @@ -55,6 +56,9 @@ def test_hash_encoding def test_unicode_escape assert_equal %{{"\\u2028":"\\u2029"}}, ActiveSupport::JSON.encode("\u2028" => "\u2029") assert_equal %{{"\u2028":"\u2029"}}, ActiveSupport::JSON.encode({ "\u2028" => "\u2029" }, escape: false) + ActiveSupport::JSON::Encoding.with(escape_js_separators_in_json: false) do + assert_equal %{{"\u2028":"\u2029"}}, ActiveSupport::JSON.encode({ "\u2028" => "\u2029" }) + end end def test_hash_keys_encoding diff --git a/guides/source/configuring.md b/guides/source/configuring.md index 772716d117620..4f8cfa3d5feb5 100644 --- a/guides/source/configuring.md +++ b/guides/source/configuring.md @@ -65,6 +65,7 @@ Below are the default values associated with each target version. In cases of co - [`config.action_view.remove_hidden_field_autocomplete`](#config-action-view-remove-hidden-field-autocomplete): `true` - [`config.action_view.render_tracker`](#config-action-view-render-tracker): `:ruby` - [`config.active_record.raise_on_missing_required_finder_order_columns`](#config-active-record-raise-on-missing-required-finder-order-columns): `true` +- [`config.active_support.escape_js_separators_in_json`](#config-active-support-escape-js-separators-in-json): `false` - [`config.yjit`](#config-yjit): `!Rails.env.local?` #### Default Values for Target Version 8.0 @@ -3020,6 +3021,20 @@ end Defaults to `nil`, which means the default `ActiveSupport::EventContext` store is used. +#### `config.active_support.escape_js_separators_in_json` + +Specifies whether LINE SEPARATOR (U+2028) and PARAGRAPH SEPARATOR (U+2029) are escaped when generating JSON. + +Historically these characters were not valid inside JavaScript literal strings but that changed in ECMAScript 2019. +As such it's no longer a concern in modern browsers: https://caniuse.com/mdn-javascript_builtins_json_json_superset. + +The default value depends on the `config.load_defaults` target version: + +| Starting with version | The default value is | +| --------------------- | -------------------- | +| (original) | `true` | +| 8.1 | `false` | + ### Configuring Active Job `config.active_job` provides the following configuration options: diff --git a/railties/lib/rails/application/configuration.rb b/railties/lib/rails/application/configuration.rb index b87f3e242a86e..c982afafb2a1a 100644 --- a/railties/lib/rails/application/configuration.rb +++ b/railties/lib/rails/application/configuration.rb @@ -365,6 +365,10 @@ def load_defaults(target_version) active_record.raise_on_missing_required_finder_order_columns = true end + if respond_to?(:active_support) + active_support.escape_js_separators_in_json = false + end + if respond_to?(:action_view) action_view.render_tracker = :ruby end diff --git a/railties/lib/rails/generators/rails/app/templates/config/initializers/new_framework_defaults_8_1.rb.tt b/railties/lib/rails/generators/rails/app/templates/config/initializers/new_framework_defaults_8_1.rb.tt index 8cf1c50e6f670..e0e60931b990b 100644 --- a/railties/lib/rails/generators/rails/app/templates/config/initializers/new_framework_defaults_8_1.rb.tt +++ b/railties/lib/rails/generators/rails/app/templates/config/initializers/new_framework_defaults_8_1.rb.tt @@ -27,6 +27,14 @@ #++ # Rails.configuration.action_controller.escape_json_responses = false +### +# Skips escaping LINE SEPARATOR (U+2028) and PARAGRAPH SEPARATOR (U+2029) in JSON. +# +# Historically these characters were not valid inside JavaScript literal strings but that changed in ECMAScript 2019. +# As such it's no longer a concern in modern browsers: https://caniuse.com/mdn-javascript_builtins_json_json_superset. +#++ +# Rails.configuration.active_support.escape_js_separators_in_json = false + ### # Raises an error when order dependent finder methods (e.g. `#first`, `#second`) are called without `order` values # on the relation, and the model does not have any order columns (`implicit_order_column`, `query_constraints`, or From 5399166e864227d11d3b894eef78398442aa161e Mon Sep 17 00:00:00 2001 From: Joshua Young Date: Sun, 28 Sep 2025 22:20:03 +1000 Subject: [PATCH 0715/1075] [Fix #55513] parallel tests hanging when worker processes die abruptly --- activesupport/CHANGELOG.md | 7 +++++++ .../active_support/testing/parallelization.rb | 13 ++++++++++++- .../testing/parallelization/server.rb | 17 +++++++++++++++-- .../testing/parallelization/worker.rb | 4 ++-- activesupport/test/parallelization_test.rb | 17 +++++++++++++++++ 5 files changed, 53 insertions(+), 5 deletions(-) diff --git a/activesupport/CHANGELOG.md b/activesupport/CHANGELOG.md index 5f9f700cd10a8..3ef94740f6eff 100644 --- a/activesupport/CHANGELOG.md +++ b/activesupport/CHANGELOG.md @@ -1,3 +1,10 @@ +* Fix parallel tests hanging when worker processes die abruptly. + + Previously, if a worker process was killed (e.g., OOM killed, `kill -9`) during parallel + test execution, the test suite would hang forever waiting for the dead worker. + + *Joshua Young* + * Add `config.active_support.escape_js_separators_in_json`. Introduce a new framework default to skip escaping LINE SEPARATOR (U+2028) and PARAGRAPH SEPARATOR (U+2029) in JSON. diff --git a/activesupport/lib/active_support/testing/parallelization.rb b/activesupport/lib/active_support/testing/parallelization.rb index 9bdc7a72f1667..ed8b20406076f 100644 --- a/activesupport/lib/active_support/testing/parallelization.rb +++ b/activesupport/lib/active_support/testing/parallelization.rb @@ -60,8 +60,19 @@ def size end def shutdown + dead_worker_pids = @worker_pool.filter_map do |pid| + Process.waitpid(pid, Process::WNOHANG) + rescue Errno::ECHILD + pid + end + @queue_server.remove_dead_workers(dead_worker_pids) + @queue_server.shutdown - @worker_pool.each { |pid| Process.waitpid pid } + @worker_pool.each do |pid| + Process.waitpid(pid) + rescue Errno::ECHILD + nil + end end end end diff --git a/activesupport/lib/active_support/testing/parallelization/server.rb b/activesupport/lib/active_support/testing/parallelization/server.rb index dc70c9616db58..1f4af7d978b2b 100644 --- a/activesupport/lib/active_support/testing/parallelization/server.rb +++ b/activesupport/lib/active_support/testing/parallelization/server.rb @@ -14,6 +14,7 @@ class Server def initialize @queue = Queue.new @active_workers = Concurrent::Map.new + @worker_pids = Concurrent::Map.new @in_flight = Concurrent::Map.new end @@ -40,12 +41,24 @@ def pop end end - def start_worker(worker_id) + def start_worker(worker_id, worker_pid) @active_workers[worker_id] = true + @worker_pids[worker_id] = worker_pid end - def stop_worker(worker_id) + def stop_worker(worker_id, worker_pid) @active_workers.delete(worker_id) + @worker_pids.delete(worker_id) + end + + def remove_dead_workers(dead_pids) + dead_pids.each do |dead_pid| + worker_id = @worker_pids.key(dead_pid) + if worker_id + @active_workers.delete(worker_id) + @worker_pids.delete(worker_id) + end + end end def active_workers? diff --git a/activesupport/lib/active_support/testing/parallelization/worker.rb b/activesupport/lib/active_support/testing/parallelization/worker.rb index fb9f0533a8954..b7e03e25dd393 100644 --- a/activesupport/lib/active_support/testing/parallelization/worker.rb +++ b/activesupport/lib/active_support/testing/parallelization/worker.rb @@ -18,7 +18,7 @@ def start DRb.stop_service @queue = DRbObject.new_with_uri(@url) - @queue.start_worker(@id) + @queue.start_worker(@id, Process.pid) begin after_fork @@ -29,7 +29,7 @@ def start set_process_title("(stopping)") run_cleanup - @queue.stop_worker(@id) + @queue.stop_worker(@id, Process.pid) end end diff --git a/activesupport/test/parallelization_test.rb b/activesupport/test/parallelization_test.rb index 1c067975011b5..29675b3665958 100644 --- a/activesupport/test/parallelization_test.rb +++ b/activesupport/test/parallelization_test.rb @@ -33,4 +33,21 @@ def teardown instance = subclass.new("test") assert_equal 5, instance.parallel_worker_id end + + test "shutdown handles dead workers gracefully" do + parallelization = ActiveSupport::Testing::Parallelization.new(1) + parallelization.start + + sleep 0.25 + + server = parallelization.instance_variable_get(:@queue_server) + assert server.active_workers? + + worker_pids = parallelization.instance_variable_get(:@worker_pool) + Process.kill("KILL", worker_pids.first) + sleep 0.25 + + Timeout.timeout(2.5, Minitest::Assertion, "Expected shutdown to not hang") { parallelization.shutdown } + assert_not server.active_workers? + end end From b3c4db5be8bf642cfd917baaa6e74bb40d9a3b64 Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Tue, 30 Sep 2025 11:09:14 +0200 Subject: [PATCH 0716/1075] Only use JSON::Coder with `json >= 2.15`. Older versions are harder to support, so it's not worth it. --- activesupport/lib/active_support/json/encoding.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/activesupport/lib/active_support/json/encoding.rb b/activesupport/lib/active_support/json/encoding.rb index 7f1529b408a4e..83379ac39a205 100644 --- a/activesupport/lib/active_support/json/encoding.rb +++ b/activesupport/lib/active_support/json/encoding.rb @@ -146,7 +146,7 @@ def stringify(jsonified) end # ruby/json 2.14.x yields non-String keys but doesn't let us know it's a key - if defined?(::JSON::Coder) && !::JSON::VERSION.start_with?("2.14.") + if defined?(::JSON::Coder) && Gem::Version.new(::JSON::VERSION) >= Gem::Version.new("2.15") class JSONGemCoderEncoder # :nodoc: JSON_NATIVE_TYPES = [Hash, Array, Float, String, Symbol, Integer, NilClass, TrueClass, FalseClass, ::JSON::Fragment].freeze CODER = ::JSON::Coder.new do |value, is_key| From 34cc80db62722803372b39486a1e1f767fb6c965 Mon Sep 17 00:00:00 2001 From: Olivier Bellone Date: Tue, 30 Sep 2025 10:09:58 -0700 Subject: [PATCH 0717/1075] Fix `Enumerable#sole` when element is a tuple Fix #55807 --- activesupport/CHANGELOG.md | 4 ++++ .../lib/active_support/core_ext/enumerable.rb | 4 ++-- .../test/core_ext/enumerable_test.rb | 20 +++++++++++++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/activesupport/CHANGELOG.md b/activesupport/CHANGELOG.md index 3ef94740f6eff..78f9974f52e6d 100644 --- a/activesupport/CHANGELOG.md +++ b/activesupport/CHANGELOG.md @@ -1,3 +1,7 @@ +* Fix `Enumerable#sole` to return the full tuple instead of just the first element of the tuple. + + *Olivier Bellone* + * Fix parallel tests hanging when worker processes die abruptly. Previously, if a worker process was killed (e.g., OOM killed, `kill -9`) during parallel diff --git a/activesupport/lib/active_support/core_ext/enumerable.rb b/activesupport/lib/active_support/core_ext/enumerable.rb index 2ab2519ba03c2..3c6fa964de414 100644 --- a/activesupport/lib/active_support/core_ext/enumerable.rb +++ b/activesupport/lib/active_support/core_ext/enumerable.rb @@ -212,12 +212,12 @@ def sole result = nil found = false - each do |element| + each do |*element| if found raise SoleItemExpectedError, "multiple items found" end - result = element + result = element.size == 1 ? element[0] : element found = true end diff --git a/activesupport/test/core_ext/enumerable_test.rb b/activesupport/test/core_ext/enumerable_test.rb index 0817fa575d33e..ea702d73a9cf8 100644 --- a/activesupport/test/core_ext/enumerable_test.rb +++ b/activesupport/test/core_ext/enumerable_test.rb @@ -401,6 +401,26 @@ def test_sole assert_raise(expected_raise) { GenericEnumerable.new(1..).sole } end + def test_sole_returns_same_value_as_first_for_tuples + enum = Enumerator.new(1) { |yielder| yielder.yield(1, "one") } + assert_equal [1, "one"], enum.sole + assert_equal enum.first, enum.sole + end + + class KeywordYielder + include Enumerable + + def each + yield 1, two: 3 + end + end + + def test_sole_keyword_arguments + yielder = KeywordYielder.new + assert_equal [1, { two: 3 }], yielder.sole + assert_equal yielder.first, yielder.sole + end + def test_doesnt_bust_constant_cache skip "Only applies to MRI" unless defined?(RubyVM.stat) From b92d3020c662ffe7c58908c0a2be70e20ebfe6a3 Mon Sep 17 00:00:00 2001 From: Yasuo Honda Date: Wed, 1 Oct 2025 15:24:35 +0900 Subject: [PATCH 0718/1075] Address `Can not start microsoftedge 17.17134 (Windows 10)` error This commit addresses the followoing Action Cable integration test error by using the supported versions of Microsoft Edge and Windows. https://buildkite.com/rails/rails/builds/122169#019999f0-9e7b-4d10-98af-e2a0844b694c ``` 30 09 2025 09:26:14.946:ERROR [launcher.sauce]: Can not start microsoftedge 17.17134 (Windows 10) [init({"version":"17.17134","platform":"Windows 10","tags":[],"name":"ActionCable JS Client","tunnel-identifier":"karma1759224357","record-video":false,"record-screenshots":false,"device-orientation":null,"disable-popup-handler":true,"build":"Buildkite 019999f0-9e7b-4d10-98af-e2a0844b694c","public":null,"commandTimeout":300,"idleTimeout":90,"maxDuration":1800,"customData":{},"base":"SauceLabs","browserName":"microsoftedge"})] The environment you requested was unavailable. Misconfigured -- Unsupported OS/browser/version/device combo: OS: 'Windows 10', Browser: 'microsoftedge', Version: '17.17134.', Device: 'unspecified' ``` Accodring to this document, minimum version of Microsoft Edge is 79. Lets' use the "latest" to see how it goes. https://saucelabs.com/products/supported-browsers-devices Windows 10 support ends on October 14, 2025, we should also bump the Windows version to 11. https://support.microsoft.com/en-us/windows/windows-10-support-ends-on-october-14-2025-2ca8b313-1946-43d3-b55c-2b95b107f281 --- actioncable/karma.conf.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/actioncable/karma.conf.js b/actioncable/karma.conf.js index bb16f969546d1..a6e03f8023760 100644 --- a/actioncable/karma.conf.js +++ b/actioncable/karma.conf.js @@ -26,7 +26,7 @@ if (process.env.CI) { sl_chrome: sauce("chrome", 70), sl_ff: sauce("firefox", 63), sl_safari: sauce("safari", "16", "macOS 13"), - sl_edge: sauce("microsoftedge", 17.17134, "Windows 10"), + sl_edge: sauce("microsoftedge", "latest", "Windows 11"), } config.browsers = Object.keys(config.customLaunchers) From 9e72fa8a1b2ecad9a7740795f798105aac8325df Mon Sep 17 00:00:00 2001 From: Fabio Sangiovanni Date: Thu, 2 Oct 2025 12:14:45 +0200 Subject: [PATCH 0719/1075] Fix error in `ActionDispatch::IntegrationTest` docs. [ci skip] Fix an example assertion that would fail in actual code. --- actionpack/lib/action_dispatch/testing/integration.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/actionpack/lib/action_dispatch/testing/integration.rb b/actionpack/lib/action_dispatch/testing/integration.rb index f283660131f1e..2cd77cbc321e7 100644 --- a/actionpack/lib/action_dispatch/testing/integration.rb +++ b/actionpack/lib/action_dispatch/testing/integration.rb @@ -621,7 +621,7 @@ def method_missing(method, ...) # end # # assert_response :success - # assert_equal({ id: Article.last.id, title: "Ahoy!" }, response.parsed_body) + # assert_equal({ "id" => Article.last.id, "title" => "Ahoy!" }, response.parsed_body) # end # end # From 4efbc17dc4fc910426491545d564cd06a1ea3a6c Mon Sep 17 00:00:00 2001 From: Sean Doyle Date: Tue, 5 Dec 2023 14:00:11 -0500 Subject: [PATCH 0720/1075] Add `request.variant` API and guides documentation Add prose and code samples for: * `request.variant=` * `request.variant` Add sections to the Action View and Action Controller guides, along with some code samples. The majority of these changes were excised from past pull requests, such as [#12977][] and [#18939][]. [#12977]: https://github.com/rails/rails/pull/12977 [#18939]: https://github.com/rails/rails/pull/18939 Co-authored-by: zzak Co-authored-by: Hartley McGuire Co-authored-by: Petrik de Heus --- .../action_dispatch/http/mime_negotiation.rb | 56 ++++++++++++++++++- guides/source/action_controller_overview.md | 48 ++++++++++++++++ guides/source/layouts_and_rendering.md | 8 ++- 3 files changed, 110 insertions(+), 2 deletions(-) diff --git a/actionpack/lib/action_dispatch/http/mime_negotiation.rb b/actionpack/lib/action_dispatch/http/mime_negotiation.rb index ccc10def6596e..2947c719f17cd 100644 --- a/actionpack/lib/action_dispatch/http/mime_negotiation.rb +++ b/actionpack/lib/action_dispatch/http/mime_negotiation.rb @@ -91,7 +91,49 @@ def formats end end - # Sets the variant for template. + # Sets the \variant for the response template. + # + # When determining which template to render, Action View will incorporate + # all variants from the request. For example, if an + # `ArticlesController#index` action needs to respond to + # `request.variant = [:ios, :turbo_native]`, it will render the + # first template file it can find in the following list: + # + # - `app/views/articles/index.html+ios.erb` + # - `app/views/articles/index.html+turbo_native.erb` + # - `app/views/articles/index.html.erb` + # + # Variants add context to the requests that views render appropriately. + # Variant names are arbitrary, and can communicate anything from the + # request's platform (`:android`, `:ios`, `:linux`, `:macos`, `:windows`) + # to its browser (`:chrome`, `:edge`, `:firefox`, `:safari`), to the type + # of user (`:admin`, `:guest`, `:user`). + # + # Note: Adding many new variant templates with similarities to existing + # template files can make maintaining your view code more difficult. + # + # #### Parameters + # + # * `variant` - a symbol name or an array of symbol names for variants + # used to render the response template + # + # #### Examples + # + # class ApplicationController < ActionController::Base + # before_action :determine_variants + # + # private + # def determine_variants + # variants = [] + # + # # some code to determine the variant(s) to use + # + # variants << :ios if request.user_agent.include?("iOS") + # variants << :turbo_native if request.user_agent.include?("Turbo Native") + # + # request.variant = variants + # end + # end def variant=(variant) variant = Array(variant) @@ -102,6 +144,18 @@ def variant=(variant) end end + # Returns the \variant for the response template as an instance of + # ActiveSupport::ArrayInquirer. + # + # request.variant = :phone + # request.variant.phone? # => true + # request.variant.tablet? # => false + # + # request.variant = [:phone, :tablet] + # request.variant.phone? # => true + # request.variant.desktop? # => false + # request.variant.any?(:phone, :desktop) # => true + # request.variant.any?(:desktop, :watch) # => false def variant @variant ||= ActiveSupport::ArrayInquirer.new end diff --git a/guides/source/action_controller_overview.md b/guides/source/action_controller_overview.md index e522b7c19cfe9..5a6e0ea72a821 100644 --- a/guides/source/action_controller_overview.md +++ b/guides/source/action_controller_overview.md @@ -1234,6 +1234,54 @@ access to the various parameters. [`query_parameters`]: https://api.rubyonrails.org/classes/ActionDispatch/Request.html#method-i-query_parameters [`request_parameters`]: https://api.rubyonrails.org/classes/ActionDispatch/Request.html#method-i-request_parameters +#### `request.variant` + +Controllers might need to tailor a response based on context-specific +information in a request. For example, controllers responding to requests from a +mobile platform might need to render different content than requests from a +desktop browser. One strategy to accomplish this is by customizing a request's +variant. Variant names are arbitrary, and can communicate anything from the +request's platform (`:anrdoid`, `:ios`, `:linux`, `:macos`, `:windows`) to its +browser (`:chrome`, `:edge`, `:firefox`, `:safari`), to the type of user +(`:admin`, `:guest`, `:user`). + +You can set the [`request.variant`](https://api.rubyonrails.org/classes/ActionDispatch/Http/MimeNegotiation.html#method-i-variant-3D) in a `before_action`: + +```ruby +request.variant = :tablet if request.user_agent.include?("iPad") +``` + +Responding with a variant in a controller action is like responding with a format: + +```ruby +# app/controllers/projects_controller.rb + +def show + # ... + respond_to do |format| + format.html do |html| + html.tablet # renders app/views/projects/show.html+tablet.erb + html.phone { extra_setup; render } # renders app/views/projects/show.html+phone.erb + end + end +end +``` + +A separate template should be created for each format and variant: + +* `app/views/projects/show.html.erb` +* `app/views/projects/show.html+tablet.erb` +* `app/views/projects/show.html+phone.erb` + +You can also simplify the variants definition using the inline syntax: + +```ruby +respond_to do |format| + format.html.tablet + format.html.phone { extra_setup; render } +end +``` + ### The `response` Object The response object is built up during the execution of the action from diff --git a/guides/source/layouts_and_rendering.md b/guides/source/layouts_and_rendering.md index 85ed0680a731b..9279898a66b7b 100644 --- a/guides/source/layouts_and_rendering.md +++ b/guides/source/layouts_and_rendering.md @@ -458,7 +458,10 @@ With this set of variants Rails will look for the following set of templates and If a template with the specified format does not exist an `ActionView::MissingTemplate` error is raised. -Instead of setting the variant on the render call you may also set it on the request object in your controller action. +Instead of setting the variant on the render call you may also set +[`request.variant`](https://api.rubyonrails.org/classes/ActionDispatch/Http/MimeNegotiation.html#method-i-variant-3D) +in your controller action. Learn more about variants in the [Action Controller +Overview](./action_controller_overview.html#request-variant) guides. ```ruby def index @@ -475,6 +478,9 @@ private end ``` +NOTE: Adding many new variant templates with similarities to existing template +files can make maintaining your view code more difficult. + #### Finding Layouts To find the current layout, Rails first looks for a file in `app/views/layouts` with the same base name as the controller. For example, rendering actions from the `PhotosController` class will use `app/views/layouts/photos.html.erb` (or `app/views/layouts/photos.builder`). If there is no such controller-specific layout, Rails will use `app/views/layouts/application.html.erb` or `app/views/layouts/application.builder`. If there is no `.erb` layout, Rails will use a `.builder` layout if one exists. Rails also provides several ways to more precisely assign specific layouts to individual controllers and actions. From e5b18066fefcfc562419a970a35197526371a69e Mon Sep 17 00:00:00 2001 From: fatkodima Date: Sat, 4 Oct 2025 18:49:12 +0300 Subject: [PATCH 0721/1075] Fix `change_column` to preserve old column attributes for sqlite3 --- .../connection_adapters/sqlite3/schema_definitions.rb | 7 +++++++ activerecord/test/cases/migration/columns_test.rb | 9 +++++++++ 2 files changed, 16 insertions(+) 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 59852cfc56d10..0323b93813f13 100644 --- a/activerecord/lib/active_record/connection_adapters/sqlite3/schema_definitions.rb +++ b/activerecord/lib/active_record/connection_adapters/sqlite3/schema_definitions.rb @@ -7,6 +7,13 @@ module SQLite3 class TableDefinition < ActiveRecord::ConnectionAdapters::TableDefinition def change_column(column_name, type, **options) name = column_name.to_s + + existing_column = @columns_hash[name] + if existing_column + existing_options = existing_column.options.except(:precision) + options = existing_options.merge(options) + end + @columns_hash[name] = nil column(name, type, **options) end diff --git a/activerecord/test/cases/migration/columns_test.rb b/activerecord/test/cases/migration/columns_test.rb index fa32c20519c3b..5c19cd79f76f7 100644 --- a/activerecord/test/cases/migration/columns_test.rb +++ b/activerecord/test/cases/migration/columns_test.rb @@ -242,6 +242,15 @@ def test_change_column_with_new_default assert_not_predicate TestModel.new, :administrator? end + def test_change_column_preserves_old_attributes + add_column "test_models", "user_id", :integer, default: 0, null: false + change_column "test_models", "user_id", :bigint + + new_column = connection.columns("test_models").find { |c| c.name == "user_id" } + assert_equal 0, new_column.default + assert_equal false, new_column.null + end + def test_change_column_with_custom_index_name add_column "test_models", "category", :string add_index :test_models, :category, name: "test_models_categories_idx" From 283d96ea53f45eedf09a31bef739575df96e87df Mon Sep 17 00:00:00 2001 From: zzak Date: Sun, 5 Oct 2025 10:31:09 +0900 Subject: [PATCH 0722/1075] Always pass default precision to BigDecimal when parsing Float in XmlMini https://github.com/ruby/bigdecimal/blob/cb2458bde33bf90a8364b58d53e8948a7ba555ea/ext/bigdecimal/bigdecimal.c#L2747-L2749 --- activesupport/lib/active_support/xml_mini.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/activesupport/lib/active_support/xml_mini.rb b/activesupport/lib/active_support/xml_mini.rb index 2c2b8185b1b80..c6d7ce5b7c251 100644 --- a/activesupport/lib/active_support/xml_mini.rb +++ b/activesupport/lib/active_support/xml_mini.rb @@ -73,6 +73,8 @@ def content_type "decimal" => Proc.new do |number| if String === number number.to_d + elsif Float === number + BigDecimal(number, 0) else BigDecimal(number) end From f1072d09d7114f5a60e352be8cc77cf07c0bdfe2 Mon Sep 17 00:00:00 2001 From: zzak Date: Sun, 5 Oct 2025 12:29:33 +0900 Subject: [PATCH 0723/1075] Fix remove_foreign_key compatibility signature Co-authored-by: Jill Klang --- .../active_record/migration/compatibility.rb | 2 +- .../cases/migration/compatibility_test.rb | 20 +++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/activerecord/lib/active_record/migration/compatibility.rb b/activerecord/lib/active_record/migration/compatibility.rb index 77fad5b1bb6ed..f9433b15e9598 100644 --- a/activerecord/lib/active_record/migration/compatibility.rb +++ b/activerecord/lib/active_record/migration/compatibility.rb @@ -33,7 +33,7 @@ def self.find(version) class V8_0 < V8_1 module RemoveForeignKeyColumnMatch - def remove_foreign_key(from_table, to_table = nil, **options) + def remove_foreign_key(*args, **options) options[:_skip_column_match] = true super end diff --git a/activerecord/test/cases/migration/compatibility_test.rb b/activerecord/test/cases/migration/compatibility_test.rb index 8ba3ce672e577..8b2e0a7516f27 100644 --- a/activerecord/test/cases/migration/compatibility_test.rb +++ b/activerecord/test/cases/migration/compatibility_test.rb @@ -718,6 +718,26 @@ def up ActiveRecord::Base.clear_cache! end + def test_remove_foreign_key_on_7_0 + connection.create_table(:sub_testings) do |t| + t.references :testing, foreign_key: true, type: :bigint + end + + migration = Class.new(ActiveRecord::Migration[7.0]) do + def up + remove_foreign_key :sub_testings, column: "testing_id" + end + end + + ActiveRecord::Migrator.new(:up, [migration], @schema_migration, @internal_metadata).migrate + + foreign_keys = @connection.foreign_keys("sub_testings") + assert_equal 0, foreign_keys.size + ensure + connection.drop_table(:sub_testings, if_exists: true) + ActiveRecord::Base.clear_cache! + end + private def precision_implicit_default if current_adapter?(:Mysql2Adapter, :TrilogyAdapter) From 497e7b3fe737f6f558e4850a678bda65b00baa9e Mon Sep 17 00:00:00 2001 From: Fried Hoeben Date: Sat, 4 Oct 2025 12:39:33 +0200 Subject: [PATCH 0724/1075] Simplify SQL statement to load records in batch --- .../lib/active_record/relation/batches.rb | 4 ++-- activerecord/test/cases/batches_test.rb | 22 +++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/activerecord/lib/active_record/relation/batches.rb b/activerecord/lib/active_record/relation/batches.rb index 2562ce1aaac13..f291b80b8f1b6 100644 --- a/activerecord/lib/active_record/relation/batches.rb +++ b/activerecord/lib/active_record/relation/batches.rb @@ -437,7 +437,7 @@ def batch_on_unloaded_relation(relation:, start:, finish:, load:, cursor:, order values = records.pluck(*cursor) values_size = values.size values_last = values.last - yielded_relation = where(cursor => values) + yielded_relation = rewhere(cursor => values) yielded_relation.load_records(records) elsif (empty_scope && use_ranges != false) || use_ranges # Efficiently peak at the last value for the next batch using offset and limit. @@ -462,7 +462,7 @@ def batch_on_unloaded_relation(relation:, start:, finish:, load:, cursor:, order values = batch_relation.pluck(*cursor) values_size = values.size values_last = values.last - yielded_relation = where(cursor => values) + yielded_relation = rewhere(cursor => values) end break if values_size == 0 diff --git a/activerecord/test/cases/batches_test.rb b/activerecord/test/cases/batches_test.rb index 43816b02580c5..19f156dcb979e 100644 --- a/activerecord/test/cases/batches_test.rb +++ b/activerecord/test/cases/batches_test.rb @@ -679,6 +679,28 @@ def test_in_batches_shouldnt_execute_query_unless_needed end end + def test_in_batches_should_unscope_cursor_after_pluck + all_ids = Post.limit(2).pluck(:id) + found_ids = [] + # only a single clause on id (i.e. not 'id IN (?,?) AND id = ?', but only 'id = ?') + assert_queries_match(/WHERE #{Regexp.escape(quote_table_name("posts.id"))} = \S+ LIMIT/) do + Post.where(id: all_ids).in_batches(of: 1) do |relation| + found_ids << relation.pick(:id) + end + end + assert_equal all_ids.sort, found_ids + end + + def test_in_batches_loaded_should_unscope_cursor_after_pluck + all_ids = Post.limit(2).pluck(:id) + # only a single clause on id (i.e. not 'id IN (?,?) AND id = ?', but only 'id = ?') + assert_queries_match(/WHERE #{Regexp.escape(quote_table_name("posts.id"))} = \S+$/) do + Post.where(id: all_ids).in_batches(of: 1, load: true) do |relation| + relation.delete_all + end + end + end + def test_in_batches_should_quote_batch_order assert_queries_match(/ORDER BY #{Regexp.escape(quote_table_name("posts.id"))}/) do Post.in_batches(of: 1) do |relation| From b182bc607041b640f1e5f51e678f3ca4ab077920 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wojciech=20Wn=C4=99trzak?= Date: Mon, 6 Oct 2025 11:41:06 +0200 Subject: [PATCH 0725/1075] Give credit to the first author of this feature Refs https://github.com/rails/rails/pull/52761 [ci skip] --- activemodel/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/activemodel/CHANGELOG.md b/activemodel/CHANGELOG.md index 27c95eba2ac64..1cd4a4a32d6ef 100644 --- a/activemodel/CHANGELOG.md +++ b/activemodel/CHANGELOG.md @@ -6,7 +6,7 @@ has_secure_password reset_token: { expires_in: 1.hour } ``` - *Jevin Sew* + *Jevin Sew*, *Abeid Ahmed* ## Rails 8.1.0.beta1 (September 04, 2025) ## From ac296a1b27fd5c7c87f3a8230cab51ec6b45ca80 Mon Sep 17 00:00:00 2001 From: zzak Date: Sun, 5 Oct 2025 09:45:18 +0900 Subject: [PATCH 0726/1075] Skip analyze job if ruby-vips or mini_magick gem are missing If you're using Active Storage, but don't have `image_processing` gem installed, because the [default analyzers][1] includes Vips and ImageMagick analyzers, you would see the following error: ``` [ActiveJob] Enqueued ActiveStorage::AnalyzeJob ... [ActiveJob] [ActiveStorage::AnalyzeJob] Performing ActiveStorage::AnalyzeJob ... [ActiveJob] [ActiveStorage::AnalyzeJob] [0efa0f81-23e0-4a09-9fb2-55ecb484c10a] Error performing ActiveStorage::AnalyzeJob: NameError (uninitialized constant Vips): ``` Previously, this would rescue LoadError because the gems were required lazily.[2] ``` def read_image begin require "ruby-vips" rescue LoadError logger.info "Skipping image analysis because the ruby-vips gem isn't installed" return {} end # ... ``` [1]: https://guides.rubyonrails.org/configuring.html#config-active-storage-analyzers [2]: https://github.com/rails/rails/blob/ab8e833991e493f57f03c10ee2a8a7fda218faae/activestorage/lib/active_storage/analyzer/image_analyzer/vips.rb#L13-L18 Co-authored-by: Earlopain <14981592+Earlopain@users.noreply.github.com> --- .../analyzer/image_analyzer/image_magick.rb | 7 +++++++ .../analyzer/image_analyzer/vips.rb | 7 +++++++ .../image_analyzer/image_magick_test.rb | 17 +++++++++++++++++ .../test/analyzer/image_analyzer/vips_test.rb | 17 +++++++++++++++++ 4 files changed, 48 insertions(+) diff --git a/activestorage/lib/active_storage/analyzer/image_analyzer/image_magick.rb b/activestorage/lib/active_storage/analyzer/image_analyzer/image_magick.rb index e15629a504f6d..63a53a8f7ed2c 100644 --- a/activestorage/lib/active_storage/analyzer/image_analyzer/image_magick.rb +++ b/activestorage/lib/active_storage/analyzer/image_analyzer/image_magick.rb @@ -3,7 +3,9 @@ begin gem "mini_magick" require "mini_magick" + ActiveStorage::MINIMAGICK_AVAILABLE = true # :nodoc: rescue LoadError => error + ActiveStorage::MINIMAGICK_AVAILABLE = false # :nodoc: raise error unless error.message.include?("mini_magick") end @@ -17,6 +19,11 @@ def self.accept?(blob) private def read_image + unless MINIMAGICK_AVAILABLE + logger.error "Skipping image analysis because the mini_magick gem isn't installed" + return {} + end + download_blob_to_tempfile do |file| image = instrument("mini_magick") do MiniMagick::Image.new(file.path) diff --git a/activestorage/lib/active_storage/analyzer/image_analyzer/vips.rb b/activestorage/lib/active_storage/analyzer/image_analyzer/vips.rb index 8f86c26e25f3e..57b9858ffbdc6 100644 --- a/activestorage/lib/active_storage/analyzer/image_analyzer/vips.rb +++ b/activestorage/lib/active_storage/analyzer/image_analyzer/vips.rb @@ -11,7 +11,9 @@ begin gem "ruby-vips" require "ruby-vips" + ActiveStorage::VIPS_AVAILABLE = true # :nodoc: rescue LoadError => error + ActiveStorage::VIPS_AVAILABLE = false # :nodoc: raise error unless error.message.match?(/libvips|ruby-vips/) end @@ -25,6 +27,11 @@ def self.accept?(blob) private def read_image + unless VIPS_AVAILABLE + logger.error "Skipping image analysis because the ruby-vips gem isn't installed" + return {} + end + download_blob_to_tempfile do |file| image = instrument("vips") do # ruby-vips will raise Vips::Error if it can't find an appropriate loader for the file diff --git a/activestorage/test/analyzer/image_analyzer/image_magick_test.rb b/activestorage/test/analyzer/image_analyzer/image_magick_test.rb index bc80288f91bd3..9635e7b83d581 100644 --- a/activestorage/test/analyzer/image_analyzer/image_magick_test.rb +++ b/activestorage/test/analyzer/image_analyzer/image_magick_test.rb @@ -70,6 +70,23 @@ class ActiveStorage::Analyzer::ImageAnalyzer::ImageMagickTest < ActiveSupport::T end end + test "when image_magick is not installed" do + stub_const(ActiveStorage, :MINIMAGICK_AVAILABLE, false) do + blob = create_file_blob(filename: "racecar.jpg", content_type: "image/jpeg") + + output = StringIO.new + logger = ActiveSupport::Logger.new(output) + + ActiveStorage.with(logger: logger) do + analyze_with_image_magick do + blob.analyze + end + end + + assert_includes output.string, "Skipping image analysis because the mini_magick gem isn't installed" + end + end + private def analyze_with_image_magick previous_processor, ActiveStorage.variant_processor = ActiveStorage.variant_processor, :mini_magick diff --git a/activestorage/test/analyzer/image_analyzer/vips_test.rb b/activestorage/test/analyzer/image_analyzer/vips_test.rb index 446986bd6929f..47bbd8942d566 100644 --- a/activestorage/test/analyzer/image_analyzer/vips_test.rb +++ b/activestorage/test/analyzer/image_analyzer/vips_test.rb @@ -58,6 +58,23 @@ class ActiveStorage::Analyzer::ImageAnalyzer::VipsTest < ActiveSupport::TestCase end end + test "when ruby-vips is not installed" do + stub_const(ActiveStorage, :VIPS_AVAILABLE, false) do + blob = create_file_blob(filename: "racecar.jpg", content_type: "image/jpeg") + + output = StringIO.new + logger = ActiveSupport::Logger.new(output) + + ActiveStorage.with(logger: logger) do + analyze_with_vips do + blob.analyze + end + end + + assert_includes output.string, "Skipping image analysis because the ruby-vips gem isn't installed" + end + end + private def analyze_with_vips previous_processor, ActiveStorage.variant_processor = ActiveStorage.variant_processor, :vips From 09a455f35173ccd11e890aba7a3b20589c2b1662 Mon Sep 17 00:00:00 2001 From: Joshua Young Date: Mon, 6 Oct 2025 21:33:58 +1000 Subject: [PATCH 0727/1075] Make `ActiveRecord::Assertions::QueryAssertions` method outputs consistent --- .../lib/active_record/testing/query_assertions.rb | 8 ++++---- .../test/cases/assertions/query_assertions_test.rb | 10 +++++----- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/activerecord/lib/active_record/testing/query_assertions.rb b/activerecord/lib/active_record/testing/query_assertions.rb index 1bf73e5165905..1d055f8f95a81 100644 --- a/activerecord/lib/active_record/testing/query_assertions.rb +++ b/activerecord/lib/active_record/testing/query_assertions.rb @@ -29,9 +29,9 @@ def assert_queries_count(count = nil, include_schema: false, &block) result = _assert_nothing_raised_or_warn("assert_queries_count", &block) queries = include_schema ? counter.log_all : counter.log if count - assert_equal count, queries.size, "#{queries.size} instead of #{count} queries were executed. Queries: #{queries.join("\n\n")}" + assert_equal count, queries.size, "#{queries.size} instead of #{count} queries were executed#{queries.empty? ? '' : ". Queries:\n\n#{queries.join("\n\n")}"}" else - assert_operator queries.size, :>=, 1, "1 or more queries expected, but none were executed.#{queries.empty? ? '' : "\nQueries:\n#{queries.join("\n")}"}" + assert_operator queries.size, :>=, 1, "1 or more queries expected, but none were executed" end result end @@ -72,9 +72,9 @@ def assert_queries_match(match, count: nil, include_schema: false, &block) matched_queries = queries.select { |query| match === query } if count - assert_equal count, matched_queries.size, "#{matched_queries.size} instead of #{count} queries were executed.#{queries.empty? ? '' : "\nQueries:\n#{queries.join("\n")}"}" + assert_equal count, matched_queries.size, "#{matched_queries.size} instead of #{count} matching queries were executed#{queries.empty? ? '' : ". Queries:\n\n#{queries.join("\n\n")}"}" else - assert_operator matched_queries.size, :>=, 1, "1 or more queries expected, but none were executed.#{queries.empty? ? '' : "\nQueries:\n#{queries.join("\n")}"}" + assert_operator matched_queries.size, :>=, 1, "1 or more matching queries expected, but none were executed#{queries.empty? ? '' : ". Queries:\n\n#{queries.join("\n\n")}"}" end result diff --git a/activerecord/test/cases/assertions/query_assertions_test.rb b/activerecord/test/cases/assertions/query_assertions_test.rb index 0d8870c9cb000..7993b237a00ba 100644 --- a/activerecord/test/cases/assertions/query_assertions_test.rb +++ b/activerecord/test/cases/assertions/query_assertions_test.rb @@ -47,12 +47,12 @@ def test_assert_queries_match error = assert_raises(Minitest::Assertion) { assert_queries_match(/ASC LIMIT/i, count: 2) { Post.first } } - assert_match(/1 instead of 2 queries/, error.message) + assert_match(/1 instead of 2 matching queries/, error.message) error = assert_raises(Minitest::Assertion) { assert_queries_match(/ASC LIMIT/i, count: 0) { Post.first } } - assert_match(/1 instead of 0 queries/, error.message) + assert_match(/1 instead of 0 matching queries/, error.message) end def test_assert_queries_match_with_matcher @@ -61,11 +61,11 @@ def test_assert_queries_match_with_matcher Post.where(id: 1).first end } - assert_match(/0 instead of 1 queries/, error.message) + assert_match(/0 instead of 1 matching queries/, error.message) end def test_assert_queries_match_when_there_are_no_queries - assert_raises(Minitest::Assertion, match: "1 or more queries expected, but none were executed") do + assert_raises(Minitest::Assertion, match: "1 or more matching queries expected, but none were executed") do assert_queries_match(/something/) { Post.none } end end @@ -113,7 +113,7 @@ def test_assert_no_queries_include_schema def test_assert_queries_match_include_schema Post.columns # load columns - assert_raises(Minitest::Assertion, match: "1 or more queries expected") do + assert_raises(Minitest::Assertion, match: "1 or more matching queries expected") do assert_queries_match(/SELECT/i, include_schema: true) { Post.columns } end From 0bb9dc9c7888cb825232491492270f801b726e33 Mon Sep 17 00:00:00 2001 From: Mathieu Alexandre Date: Mon, 6 Oct 2025 14:40:35 +0000 Subject: [PATCH 0728/1075] Refactor SSE#write to perform a single stream write per event --- .../lib/action_controller/metal/live.rb | 7 +- .../test/controller/sse_perform_write_test.rb | 76 +++++++++++++++++++ 2 files changed, 80 insertions(+), 3 deletions(-) create mode 100644 actionpack/test/controller/sse_perform_write_test.rb diff --git a/actionpack/lib/action_controller/metal/live.rb b/actionpack/lib/action_controller/metal/live.rb index a449f522c7957..4838ed70163a5 100644 --- a/actionpack/lib/action_controller/metal/live.rb +++ b/actionpack/lib/action_controller/metal/live.rb @@ -133,15 +133,16 @@ def write(object, options = {}) private def perform_write(json, options) current_options = @options.merge(options).stringify_keys - + event = +"" PERMITTED_OPTIONS.each do |option_name| if (option_value = current_options[option_name]) - @stream.write "#{option_name}: #{option_value}\n" + event << "#{option_name}: #{option_value}\n" end end message = json.gsub("\n", "\ndata: ") - @stream.write "data: #{message}\n\n" + event << "data: #{message}\n\n" + @stream.write event end end diff --git a/actionpack/test/controller/sse_perform_write_test.rb b/actionpack/test/controller/sse_perform_write_test.rb new file mode 100644 index 0000000000000..a8f54152c6c3e --- /dev/null +++ b/actionpack/test/controller/sse_perform_write_test.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require "abstract_unit" + +module ActionController + # Unit tests for ActionController::Live::SSE focusing on underlying write semantics. + # These tests ensure that each call to SSE#write results in exactly one call to the + # underlying stream#write, even when multiple options (event, retry, id) are supplied + # and when multiline data requires transformation. + class SSEPerformWriteTest < ActiveSupport::TestCase + class FakeStream + attr_reader :writes + + def initialize + @writes = [] + end + + def write(chunk) + @writes << chunk + end + + def close; end + end + + def test_single_underlying_write_with_options_and_object_payload + stream = FakeStream.new + sse = Live::SSE.new(stream, event: "base", retry: 100) + + sse.write({ name: "John" }, id: 123, event: "override", retry: 500) + + assert_equal 1, stream.writes.size, "Expected exactly one underlying write call" + payload = stream.writes.first + + assert_match(/event: override/, payload) + assert_match(/retry: 500/, payload) + assert_match(/id: 123/, payload) + assert_match(/data: {"name":"John"}/, payload) + assert_match(/\n\n\z/, payload, "Payload should terminate with a blank line per SSE spec") + end + + def test_single_underlying_write_with_preencoded_string + stream = FakeStream.new + sse = Live::SSE.new(stream) + + sse.write("{\"a\":1}") + + assert_equal 1, stream.writes.size + assert_match(/data: {"a":1}/, stream.writes.first) + end + + def test_single_underlying_write_with_multiline_string + stream = FakeStream.new + sse = Live::SSE.new(stream) + + sse.write("line1\nline2", event: "multi") + + assert_equal 1, stream.writes.size + payload = stream.writes.first + # Each newline becomes a new data: line (after the first) but still one underlying write + assert_match(/event: multi/, payload) + assert_match(/data: line1/, payload) + assert_match(/data: line2/, payload) + end + + def test_number_of_underlying_writes_matches_number_of_sse_writes + stream = FakeStream.new + sse = Live::SSE.new(stream) + + sse.write(a: 1) + sse.write(b: 2, id: 10) + sse.write({ c: 3 }, event: "evt", retry: 2500) + + assert_equal 3, stream.writes.size, "Each SSE#write should map to exactly one stream.write" + end + end +end From 851ee82478fbed697e87a8871174fc14b5584bac Mon Sep 17 00:00:00 2001 From: Harsh Date: Mon, 6 Oct 2025 13:11:58 -0400 Subject: [PATCH 0729/1075] add to association basics too and clean up phrasing --- guides/source/active_record_basics.md | 2 ++ guides/source/active_record_migrations.md | 2 ++ guides/source/association_basics.md | 2 ++ guides/source/command_line.md | 2 +- 4 files changed, 7 insertions(+), 1 deletion(-) diff --git a/guides/source/active_record_basics.md b/guides/source/active_record_basics.md index 81d31958e8b14..98171ff1801bb 100644 --- a/guides/source/active_record_basics.md +++ b/guides/source/active_record_basics.md @@ -205,6 +205,8 @@ SQL. A migration for the `books` table above can be generated like this: $ bin/rails generate migration CreateBooks title:string author:string ``` +NOTE: If you don't specify a type for a field (e.g., `title` instead of `title:string`), Rails will default to type `string`. + and results in this: ```ruby diff --git a/guides/source/active_record_migrations.md b/guides/source/active_record_migrations.md index 7c7445d265b83..76ea4f977b5a6 100644 --- a/guides/source/active_record_migrations.md +++ b/guides/source/active_record_migrations.md @@ -162,6 +162,8 @@ class CreateProducts < ActiveRecord::Migration[8.1] end ``` +NOTE: If you don't specify a type for a field (e.g., `name` instead of `name:string`), Rails will default to type `string`. + The generated file with its contents is just a starting point, and you can add or remove from it as you see fit by editing the `db/migrate/YYYYMMDDHHMMSS_create_products.rb` file. diff --git a/guides/source/association_basics.md b/guides/source/association_basics.md index da28d97d8d25c..c8c7c7651e549 100644 --- a/guides/source/association_basics.md +++ b/guides/source/association_basics.md @@ -1978,6 +1978,8 @@ $ bin/rails generate model message subject:string body:string $ bin/rails generate model comment content:string ``` +NOTE: If you don't specify a type for a field (e.g., `subject` instead of `subject:string`), Rails will default to type `string`. + After running the generators, our models should look like this: ```ruby diff --git a/guides/source/command_line.md b/guides/source/command_line.md index b4ee6fb96921d..663363128175e 100644 --- a/guides/source/command_line.md +++ b/guides/source/command_line.md @@ -333,7 +333,7 @@ Description: ... ``` -NOTE: For a list of available field types for the `type` parameter, refer to the [API documentation](https://api.rubyonrails.org/classes/ActiveRecord/ConnectionAdapters/SchemaStatements.html#method-i-add_column) for the add_column method for the `SchemaStatements` module. The `index` parameter generates a corresponding index for the column. +NOTE: For a list of available field types for the `type` parameter, refer to the [API documentation](https://api.rubyonrails.org/classes/ActiveRecord/ConnectionAdapters/SchemaStatements.html#method-i-add_column) for the add_column method for the `SchemaStatements` module. The `index` parameter generates a corresponding index for the column. If you don't specify a type for a field, Rails will default to type `string`. But instead of generating a model directly (which we'll be doing later), let's set up a scaffold. A **scaffold** in Rails is a full set of model, database migration for that model, controller to manipulate it, views to view and manipulate the data, and a test suite for each of the above. From 2d084af8444a7b6b868c7cbb256172007871e78d Mon Sep 17 00:00:00 2001 From: Duncan Brown Date: Sun, 3 May 2020 21:39:35 +0100 Subject: [PATCH 0730/1075] =?UTF-8?q?Don=E2=80=99t=20ignore=20X-Forwarded-?= =?UTF-8?q?For=20IPs=20with=20ports=20attached?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rack decided to tolerate proxies which choose to attach ports to X-Forwarded-For IPs by stripping the port: https://github.com/rack/rack/pull/1251. Attaching a port is rare in the wild but some proxies (notably Microsoft Azure's App Service) do it. Without this patch, remote_ip will ignore X-Forwarded-For IPs with ports attached and the return value is less likely to be useful. Rails should do the same thing. The stripping logic is already available in Rack::Request::Helpers, so change the X-Forwarded-For retrieval method from ActionDispatch::Request#x_forwarded_for (which returns the raw header) to #forwarded_for, which returns a stripped array of IP addresses, or nil. There may be other benefits hiding in Rack's implementation. We can't call ips_from with an array (and legislating for that inside ips_from doesn't appeal), so refactor out the bit we need to apply in both cases (verifying the IP is acceptable to IPAddr and that it's not a range) to a separate method called #sanitize_ips which reduces an array of maybe-ips to an array of acceptable ones. --- Note: I've partially modified the code for the latest Rails and added a test case for IPv6 (Masafumi Koba). Originated from https://github.com/rails/rails/pull/39134 Co-authored-by: Masafumi Koba <473530+ybiquitous@users.noreply.github.com> --- actionpack/CHANGELOG.md | 5 +++++ .../lib/action_dispatch/middleware/remote_ip.rb | 11 +++++++---- actionpack/test/dispatch/request_test.rb | 6 ++++++ 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/actionpack/CHANGELOG.md b/actionpack/CHANGELOG.md index a4b765cfcfd1d..756e74bd06729 100644 --- a/actionpack/CHANGELOG.md +++ b/actionpack/CHANGELOG.md @@ -1,3 +1,8 @@ +* `remote_ip` will no longer ignore IPs in X-Forwarded-For headers if they + are accompanied by port information. + + *Duncan Brown*, *Prevenios Marinos*, *Masafumi Koba*, *Adam Daniels* + * Add `action_dispatch.verbose_redirect_logs` setting that logs where redirects were called from. Similar to `active_record.verbose_query_logs` and `active_job.verbose_enqueue_logs`, this adds a line in your logs that shows where a redirect was called from. diff --git a/actionpack/lib/action_dispatch/middleware/remote_ip.rb b/actionpack/lib/action_dispatch/middleware/remote_ip.rb index d665a7fdabe43..9b50568e9365b 100644 --- a/actionpack/lib/action_dispatch/middleware/remote_ip.rb +++ b/actionpack/lib/action_dispatch/middleware/remote_ip.rb @@ -126,11 +126,11 @@ def initialize(req, check_ip, proxies) # left, which was presumably set by one of those proxies. def calculate_ip # Set by the Rack web server, this is a single value. - remote_addr = ips_from(@req.remote_addr).last + remote_addr = sanitize_ips(ips_from(@req.remote_addr)).last # Could be a CSV list and/or repeated headers that were concatenated. - client_ips = ips_from(@req.client_ip).reverse! - forwarded_ips = ips_from(@req.x_forwarded_for).reverse! + client_ips = sanitize_ips(ips_from(@req.client_ip)).reverse! + forwarded_ips = sanitize_ips(@req.forwarded_for || []).reverse! # `Client-Ip` and `X-Forwarded-For` should not, generally, both be set. If they # are both set, it means that either: @@ -176,7 +176,10 @@ def to_s def ips_from(header) # :doc: return [] unless header # Split the comma-separated list into an array of strings. - ips = header.strip.split(/[,\s]+/) + header.strip.split(/[,\s]+/) + end + + def sanitize_ips(ips) # :doc: ips.select! do |ip| # Only return IPs that are valid according to the IPAddr#new method. range = IPAddr.new(ip).to_range diff --git a/actionpack/test/dispatch/request_test.rb b/actionpack/test/dispatch/request_test.rb index 8a6f25fba26c7..ac7e2330de1ae 100644 --- a/actionpack/test/dispatch/request_test.rb +++ b/actionpack/test/dispatch/request_test.rb @@ -105,6 +105,9 @@ class RequestIP < BaseRequestTest request = stub_request "HTTP_X_FORWARDED_FOR" => "3.4.5.6,127.0.0.1" assert_equal "3.4.5.6", request.remote_ip + request = stub_request "HTTP_X_FORWARDED_FOR" => "3.4.5.6:1234,127.0.0.1" + assert_equal "3.4.5.6", request.remote_ip + request = stub_request "HTTP_X_FORWARDED_FOR" => "unknown,192.168.0.1" assert_equal "192.168.0.1", request.remote_ip @@ -164,6 +167,9 @@ class RequestIP < BaseRequestTest request = stub_request "HTTP_X_FORWARDED_FOR" => "fe80:0000:0000:0000:0202:b3ff:fe1e:8329,unknown" assert_equal "fe80:0000:0000:0000:0202:b3ff:fe1e:8329", request.remote_ip + request = stub_request "HTTP_X_FORWARDED_FOR" => "[fe80:0000:0000:0000:0202:b3ff:fe1e:8329]:3000,unknown" + assert_equal "fe80:0000:0000:0000:0202:b3ff:fe1e:8329", request.remote_ip + request = stub_request "HTTP_X_FORWARDED_FOR" => "fe80:0000:0000:0000:0202:b3ff:fe1e:8329,::1" assert_equal "fe80:0000:0000:0000:0202:b3ff:fe1e:8329", request.remote_ip From a738360867ae3f95bef8263d38c56ffde5d16da9 Mon Sep 17 00:00:00 2001 From: Harsh Date: Mon, 6 Oct 2025 13:57:23 -0400 Subject: [PATCH 0731/1075] Improve flow in Getting Started guide: create edit view before adding edit link The guide previously instructed readers to add an Edit link to the show page before creating the edit.html.erb view, which would result in broken links. This commit reorders the sections to: 1. First introduce partials and create _form.html.erb 2. Update new.html.erb to use the partial 3. Create edit.html.erb using the partial 4. Then add the Edit link to show.html.erb This ensures the edit view exists before we link to it, providing a smoother tutorial experience for readers following along. --- guides/source/getting_started.md | 108 +++++++++++++++---------------- 1 file changed, 54 insertions(+), 54 deletions(-) diff --git a/guides/source/getting_started.md b/guides/source/getting_started.md index 0624ad39a47fa..8eceecd71f4d4 100644 --- a/guides/source/getting_started.md +++ b/guides/source/getting_started.md @@ -1335,7 +1335,60 @@ class ProductsController < ApplicationController end ``` -Next we can add an Edit link to `app/views/products/show.html.erb`: +#### Extracting Partials + +We've already written a form for creating new products. Wouldn't it be nice if +we could reuse that for edit and update? We can, using a feature called +"partials" that allows you to reuse a view in multiple places. + +We can move the form into a file called `app/views/products/_form.html.erb`. The +filename starts with an underscore to denote this is a partial. + +We also want to replace any instance variables with a local variable, which we +can define when we render the partial. We'll do this by replacing `@product` +with `product`. + +```erb#1 +<%= form_with model: product do |form| %> +
+ <%= form.label :name %> + <%= form.text_field :name %> +
+ +
+ <%= form.submit %> +
+<% end %> +``` + +TIP: Using local variables allows partials to be reused multiple times on the +same page with a different value each time. This comes in handy rendering lists +of items like an index page. + +To use this partial in our `app/views/products/new.html.erb` view, we can +replace the form with a render call: + +```erb#3 +

New product

+ +<%= render "form", product: @product %> +<%= link_to "Cancel", products_path %> +``` + +The edit view becomes almost the exact same thing thanks to the form partial. +Let's create `app/views/products/edit.html.erb` with the following: + +```erb#3 +

Edit product

+ +<%= render "form", product: @product %> +<%= link_to "Cancel", @product %> +``` + +To learn more about view partials, check out the +[Action View Guide](action_view_overview.html). + +Now we can add an Edit link to `app/views/products/show.html.erb`: ```erb#4

<%= @product.name %>

@@ -1403,59 +1456,6 @@ class ProductsController < ApplicationController end ``` -#### Extracting Partials - -We've already written a form for creating new products. Wouldn't it be nice if -we could reuse that for edit and update? We can, using a feature called -"partials" that allows you to reuse a view in multiple places. - -We can move the form into a file called `app/views/products/_form.html.erb`. The -filename starts with an underscore to denote this is a partial. - -We also want to replace any instance variables with a local variable, which we -can define when we render the partial. We'll do this by replacing `@product` -with `product`. - -```erb#1 -<%= form_with model: product do |form| %> -
- <%= form.label :name %> - <%= form.text_field :name %> -
- -
- <%= form.submit %> -
-<% end %> -``` - -TIP: Using local variables allows partials to be reused multiple times on the -same page with a different value each time. This comes in handy rendering lists -of items like an index page. - -To use this partial in our `app/views/products/new.html.erb` view, we can -replace the form with a render call: - -```erb#3 -

New product

- -<%= render "form", product: @product %> -<%= link_to "Cancel", products_path %> -``` - -The edit view becomes almost the exact same thing thanks to the form partial. -Let's create `app/views/products/edit.html.erb` with the following: - -```erb#3 -

Edit product

- -<%= render "form", product: @product %> -<%= link_to "Cancel", @product %> -``` - -To learn more about view partials, check out the -[Action View Guide](action_view_overview.html). - ### Deleting Products The last feature we need to implement is deleting products. We will add a From d4664da8584a5d51b3f98bd78e81990722273c8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Mon, 6 Oct 2025 18:21:18 +0000 Subject: [PATCH 0732/1075] Only include The session helper in integration tests Model and other kind of tests should not have a `login_as` method available. --- .../test_unit/authentication/authentication_generator.rb | 1 - .../templates/test/test_helpers/session_test_helper.rb.tt | 4 ++++ railties/test/generators/authentication_generator_test.rb | 6 ++---- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/railties/lib/rails/generators/test_unit/authentication/authentication_generator.rb b/railties/lib/rails/generators/test_unit/authentication/authentication_generator.rb index 7357bcd12ac2d..43b9dd1f91f86 100644 --- a/railties/lib/rails/generators/test_unit/authentication/authentication_generator.rb +++ b/railties/lib/rails/generators/test_unit/authentication/authentication_generator.rb @@ -25,7 +25,6 @@ def create_test_helper_files def configure_test_helper inject_into_file "test/test_helper.rb", "require_relative \"test_helpers/session_test_helper\"\n", after: "require \"rails/test_help\"\n" - inject_into_class "test/test_helper.rb", "TestCase", " include SessionTestHelper\n" end end end diff --git a/railties/lib/rails/generators/test_unit/authentication/templates/test/test_helpers/session_test_helper.rb.tt b/railties/lib/rails/generators/test_unit/authentication/templates/test/test_helpers/session_test_helper.rb.tt index fa66ac38095d9..5ba224377911b 100644 --- a/railties/lib/rails/generators/test_unit/authentication/templates/test/test_helpers/session_test_helper.rb.tt +++ b/railties/lib/rails/generators/test_unit/authentication/templates/test/test_helpers/session_test_helper.rb.tt @@ -13,3 +13,7 @@ module SessionTestHelper cookies.delete(:session_id) end end + +ActiveSupport.on_load(:action_dispatch_integration_test) do + include SessionTestHelper +end diff --git a/railties/test/generators/authentication_generator_test.rb b/railties/test/generators/authentication_generator_test.rb index 82a400eef60cb..f88bc763b3a1b 100644 --- a/railties/test/generators/authentication_generator_test.rb +++ b/railties/test/generators/authentication_generator_test.rb @@ -67,8 +67,7 @@ def test_authentication_generator assert_file "test/test_helpers/session_test_helper.rb" assert_file "test/test_helper.rb" do |content| - assert_match(/session_test_helper/, content) - assert_match(/SessionTestHelper/, content) + assert_match("require_relative \"test_helpers/session_test_helper\"", content) end end @@ -119,8 +118,7 @@ def test_authentication_generator_with_api_flag assert_file "test/test_helpers/session_test_helper.rb" assert_file "test/test_helper.rb" do |content| - assert_match(/session_test_helper/, content) - assert_match(/SessionTestHelper/, content) + assert_match("require_relative \"test_helpers/session_test_helper\"", content) end end From b164bb832e8e11e7fb10f3c47d93ba29a98f2a94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Mon, 6 Oct 2025 18:32:50 +0000 Subject: [PATCH 0733/1075] Properly delete session cookie using string key rack-test expects string keys for cookies, not symbols. See https://github.com/rack/rack-test/pull/356. --- .../templates/test/test_helpers/session_test_helper.rb.tt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/railties/lib/rails/generators/test_unit/authentication/templates/test/test_helpers/session_test_helper.rb.tt b/railties/lib/rails/generators/test_unit/authentication/templates/test/test_helpers/session_test_helper.rb.tt index 5ba224377911b..0686378cf87b0 100644 --- a/railties/lib/rails/generators/test_unit/authentication/templates/test/test_helpers/session_test_helper.rb.tt +++ b/railties/lib/rails/generators/test_unit/authentication/templates/test/test_helpers/session_test_helper.rb.tt @@ -4,13 +4,13 @@ module SessionTestHelper ActionDispatch::TestRequest.create.cookie_jar.tap do |cookie_jar| cookie_jar.signed[:session_id] = Current.session.id - cookies[:session_id] = cookie_jar[:session_id] + cookies["session_id"] = cookie_jar[:session_id] end end def sign_out Current.session&.destroy! - cookies.delete(:session_id) + cookies.delete("session_id") end end From d266aebdf702ca4fbd6d4877020773b309eb0fbb Mon Sep 17 00:00:00 2001 From: Ben Garcia Date: Mon, 6 Oct 2025 13:58:52 -0700 Subject: [PATCH 0734/1075] Refactor gcs_service public interface to bring more in line with s3_service The s3_service exposes it's respective client and bucket attributes publicly. I think this makes sense in the context of active storage, as it allows users to rely on the same adapter & configs that the framework is using for Activestorage without having to duplicate your own client and ensure it matches the original, which can be modified at runtime, in order to perform operations outside of the scope of ActiveStorage. This commit serves to make the same true of the gcs_service, which currently defined it's respective client & bucket as private method definitions. I did a bit of git snooping and didn't see any mention of this being an intentional decision when first released. Lint --- .../lib/active_storage/service/gcs_service.rb | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/activestorage/lib/active_storage/service/gcs_service.rb b/activestorage/lib/active_storage/service/gcs_service.rb index c0696638df4ac..2e30ad9490d15 100644 --- a/activestorage/lib/active_storage/service/gcs_service.rb +++ b/activestorage/lib/active_storage/service/gcs_service.rb @@ -10,12 +10,17 @@ module ActiveStorage # Wraps the Google Cloud Storage as an Active Storage service. See ActiveStorage::Service for the generic API # documentation that applies to all services. class Service::GCSService < Service + attr_reader :client, :bucket class MetadataServerError < ActiveStorage::Error; end class MetadataServerNotFoundError < ActiveStorage::Error; end def initialize(public: false, **config) - @config = config + @client = Google::Cloud::Storage.new(**config.except(:bucket, :cache_control, :iam, :gsa_email)) + @bucket = @client.bucket(config.fetch(:bucket), skip_lookup: true) + @public = public + + @config = config end def upload(key, io, checksum: nil, content_type: nil, disposition: nil, filename: nil, custom_metadata: {}) @@ -188,14 +193,6 @@ def stream(key) end end - def bucket - @bucket ||= client.bucket(config.fetch(:bucket), skip_lookup: true) - end - - def client - @client ||= Google::Cloud::Storage.new(**config.except(:bucket, :cache_control, :iam, :gsa_email)) - end - def issuer @issuer ||= @config[:gsa_email].presence || email_from_metadata_server end From 4ae5bfc3a04db36d0f274b272eb68d6136ffb63b Mon Sep 17 00:00:00 2001 From: Emmanuel Hayford Date: Sun, 5 Oct 2025 15:23:13 +0200 Subject: [PATCH 0735/1075] Place template annotation on a separate line The BEGIN template annotation/comment was previously printed on the same line as the following element. We now insert a newline inside the comment so it spans two lines without adding visible whitespace to the HTML output to enhance readability. --- actionview/CHANGELOG.md | 14 ++++++++++++++ .../lib/action_view/template/handlers/erb.rb | 2 +- .../test/actionpack/controller/render_test.rb | 4 +++- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/actionview/CHANGELOG.md b/actionview/CHANGELOG.md index 75510fba4af96..3cbabf761e508 100644 --- a/actionview/CHANGELOG.md +++ b/actionview/CHANGELOG.md @@ -1,3 +1,17 @@ +* The BEGIN template annotation/comment was previously printed on the same line as the following element. We now insert a newline inside the comment so it spans two lines without adding visible whitespace to the HTML output to enhance readability. + + Before: + ``` +

This is grand!

+ ``` + + After: + ``` +

This is grand!

+ ``` + *Emmanuel Hayford* + * Add structured events for Action View: - `action_view.render_template` - `action_view.render_partial` diff --git a/actionview/lib/action_view/template/handlers/erb.rb b/actionview/lib/action_view/template/handlers/erb.rb index 45c9270d96b9e..2272bb4bc19ec 100644 --- a/actionview/lib/action_view/template/handlers/erb.rb +++ b/actionview/lib/action_view/template/handlers/erb.rb @@ -86,7 +86,7 @@ def call(template, source) } if ActionView::Base.annotate_rendered_view_with_filenames && template.format == :html - options[:preamble] = "@output_buffer.safe_append='';" + options[:preamble] = "@output_buffer.safe_append='';" options[:postamble] = "@output_buffer.safe_append='';@output_buffer" end diff --git a/actionview/test/actionpack/controller/render_test.rb b/actionview/test/actionpack/controller/render_test.rb index 8c2a9df0b4adc..278a9a1b0b990 100644 --- a/actionview/test/actionpack/controller/render_test.rb +++ b/actionview/test/actionpack/controller/render_test.rb @@ -1513,6 +1513,8 @@ def test_template_annotations get :greeting end + assert_match(/| -->)/, @response.body) + assert_includes @response.body, " + + <%= rich_textarea :message, :content, input: "trix_input_1" do %> +

hello world

+ <% end %> + + + <%= form_with model: Message.new do |form| %> + <%= form.rich_textarea :content do %> +

hello world

+ <% end %> + <% end %> + + ``` + + *Sean Doyle* + * Generalize `:rich_text_area` Capybara selector Prepare for more Action Text-capable WYSIWYG editors by making diff --git a/actiontext/app/helpers/action_text/tag_helper.rb b/actiontext/app/helpers/action_text/tag_helper.rb index 375eafcf9b01e..a2ed7b76eeb11 100644 --- a/actiontext/app/helpers/action_text/tag_helper.rb +++ b/actiontext/app/helpers/action_text/tag_helper.rb @@ -27,7 +27,14 @@ module TagHelper # rich_textarea_tag "content", message.content # # # # - def rich_textarea_tag(name, value = nil, options = {}) + # + # rich_textarea_tag "content", nil do + # "

Default content

" + # end + # # + # # + def rich_textarea_tag(name, value = nil, options = {}, &block) + value = capture(&block) if value.nil? && block_given? options = options.symbolize_keys form = options.delete(:form) @@ -53,11 +60,11 @@ class Tags::ActionText < Tags::Base delegate :dom_id, to: ActionView::RecordIdentifier - def render + def render(&block) options = @options.stringify_keys add_default_name_and_field(options) options["input"] ||= dom_id(object, [options["id"], :trix_input].compact.join("_")) if object - html_tag = @template_object.rich_textarea_tag(options.delete("name"), options.fetch("value") { value }, options.except("value")) + html_tag = @template_object.rich_textarea_tag(options.delete("name"), options.fetch("value") { value }, options.except("value"), &block) error_wrapping(html_tag) end end @@ -82,10 +89,16 @@ module FormHelper # # # # rich_textarea :message, :content, value: "

Default message

" - # # + # # + # # + # + # rich_textarea :message, :content do + # "

Default message

" + # end + # # # # - def rich_textarea(object_name, method, options = {}) - Tags::ActionText.new(object_name, method, self, options).render + def rich_textarea(object_name, method, options = {}, &block) + Tags::ActionText.new(object_name, method, self, options).render(&block) end alias_method :rich_text_area, :rich_textarea end @@ -98,8 +111,8 @@ class FormBuilder # <% end %> # # Please refer to the documentation of the base helper for details. - def rich_textarea(method, options = {}) - @template.rich_textarea(@object_name, method, objectify_options(options)) + def rich_textarea(method, options = {}, &block) + @template.rich_textarea(@object_name, method, objectify_options(options), &block) end alias_method :rich_text_area, :rich_textarea end diff --git a/actiontext/test/template/form_helper_test.rb b/actiontext/test/template/form_helper_test.rb index 7c3e05b8d7006..da69d4bfe6675 100644 --- a/actiontext/test/template/form_helper_test.rb +++ b/actiontext/test/template/form_helper_test.rb @@ -37,6 +37,20 @@ def form_with(*, **) HTML end + test "#rich_textarea_tag helper with block" do + concat( + rich_textarea_tag(:content, nil, { input: "trix_input_1" }) do + concat "

hello world

" + end + ) + + assert_dom_equal(<<~HTML, output_buffer) + + + + HTML + end + test "#rich_textarea helper" do concat rich_textarea :message, :content, input: "trix_input_1" @@ -47,6 +61,20 @@ def form_with(*, **) HTML end + test "#rich_textarea helper with block" do + concat( + rich_textarea(:message, :content, input: "trix_input_1") do + concat "

hello world

" + end + ) + + assert_dom_equal(<<~HTML, output_buffer) + + + + HTML + end + test "#rich_textarea helper renders the :value argument into the hidden field" do message = Message.new content: "

hello world

" @@ -73,6 +101,22 @@ def form_with(*, **) HTML end + test "form with rich text area with block" do + form_with model: Message.new do |form| + form.rich_textarea :content do + "

hello world

" + end + end + + assert_dom_equal(<<~HTML, output_buffer) + + + + + + HTML + end + test "form with rich text area having class" do form_with model: Message.new, scope: :message do |form| form.rich_textarea :content, class: "custom-class" @@ -134,6 +178,22 @@ def form_with(*, **) HTML end + test "modelless form with rich text area with block" do + form_with url: "/messages", scope: :message do |form| + form.rich_textarea :content, input: "trix_input_1" do + "

hello world

" + end + end + + assert_dom_equal(<<~HTML, output_buffer) +
+ + + +
+ HTML + end + test "form with rich text area having placeholder without locale" do form_with model: Message.new, scope: :message do |form| form.rich_textarea :content, placeholder: true @@ -178,6 +238,24 @@ def form_with(*, **) HTML end + test "form with rich text area with value with block" do + model = Message.new content: "

ignored

" + + form_with model: model, scope: :message do |form| + form.rich_textarea :title do + "

hello world

" + end + end + + assert_dom_equal(<<~HTML, output_buffer) +
+ + + +
+ HTML + end + test "form with rich text area with form attribute" do form_with model: Message.new, scope: :message do |form| form.rich_textarea :title, form: "other_form" From a32f1239dda1c2951c3e5f2944b3a58fc2e38d08 Mon Sep 17 00:00:00 2001 From: Gannon McGibbon Date: Wed, 8 Oct 2025 12:19:57 -0500 Subject: [PATCH 0746/1075] Allow event payload filter to update after app configuration If an event is emitted before app configuration, this will break parameter filtering. Instead, we can reload the filter when the app is finished booting. --- .../lib/active_support/event_reporter.rb | 5 +++++ activesupport/lib/active_support/railtie.rb | 1 + activesupport/test/event_reporter_test.rb | 21 +++++++++++++++++++ 3 files changed, 27 insertions(+) diff --git a/activesupport/lib/active_support/event_reporter.rb b/activesupport/lib/active_support/event_reporter.rb index b43bcd5577bba..0f4f8a19b7561 100644 --- a/activesupport/lib/active_support/event_reporter.rb +++ b/activesupport/lib/active_support/event_reporter.rb @@ -531,6 +531,11 @@ def context context_store.context end + def reload_payload_filter # :nodoc: + @payload_filter = nil + payload_filter + end + private def raise_on_error? @raise_on_error diff --git a/activesupport/lib/active_support/railtie.rb b/activesupport/lib/active_support/railtie.rb index 3a370fef49e95..5798a70e60322 100644 --- a/activesupport/lib/active_support/railtie.rb +++ b/activesupport/lib/active_support/railtie.rb @@ -82,6 +82,7 @@ class Railtie < Rails::Railtie # :nodoc: initializer "active_support.set_filter_parameters" do |app| config.after_initialize do ActiveSupport.filter_parameters += Rails.application.config.filter_parameters + ActiveSupport.event_reporter.reload_payload_filter end end diff --git a/activesupport/test/event_reporter_test.rb b/activesupport/test/event_reporter_test.rb index c25f814fa6705..b1018b90de229 100644 --- a/activesupport/test/event_reporter_test.rb +++ b/activesupport/test/event_reporter_test.rb @@ -565,6 +565,27 @@ class ContextStoreTest < ActiveSupport::TestCase end end end + + test "payload filter reloading" do + @reporter.notify(:some_event, test: true) + ActiveSupport.filter_parameters << :param_to_be_filtered + + assert_called_with(@subscriber, :emit, [ + event_matcher(name: "some_event", payload: { param_to_be_filtered: "test" }) + ]) do + @reporter.notify(:some_event, param_to_be_filtered: "test") + end + + @reporter.reload_payload_filter + + assert_called_with(@subscriber, :emit, [ + event_matcher(name: "some_event", payload: { param_to_be_filtered: "[FILTERED]" }) + ]) do + @reporter.notify(:some_event, param_to_be_filtered: "test") + end + ensure + ActiveSupport.filter_parameters.pop + end end class EncodersTest < ActiveSupport::TestCase From 0abcb526e6c6e2ce3d901d45d1130337da0d3914 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Fri, 10 Oct 2025 00:35:40 +0000 Subject: [PATCH 0747/1075] Don't use moby in the docker-outside-of-docker feature Moby isn't available in debian trixie. that is now used for the base image. See https://github.com/devcontainers/features/blob/eea561a9ad45c383c2d9832d2da031410be8b5a0/src/docker-outside-of-docker/install.sh#L205-L209 --- .../generators/rails/devcontainer/devcontainer_generator.rb | 2 +- .../devcontainer/templates/devcontainer/devcontainer.json.tt | 2 +- railties/test/generators/generators_test_helper.rb | 3 +++ 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/railties/lib/rails/generators/rails/devcontainer/devcontainer_generator.rb b/railties/lib/rails/generators/rails/devcontainer/devcontainer_generator.rb index 5bfe060fb850f..bcfb7eee3fce3 100644 --- a/railties/lib/rails/generators/rails/devcontainer/devcontainer_generator.rb +++ b/railties/lib/rails/generators/rails/devcontainer/devcontainer_generator.rb @@ -112,7 +112,7 @@ def features @features["ghcr.io/rails/devcontainer/features/activestorage"] = {} if options[:active_storage] @features["ghcr.io/devcontainers/features/node:1"] = {} if options[:node] - @features["ghcr.io/devcontainers/features/docker-outside-of-docker:1"] = {} if options[:kamal] + @features["ghcr.io/devcontainers/features/docker-outside-of-docker:1"] = { moby: false } if options[:kamal] @features.merge!(database.feature) if database.feature diff --git a/railties/lib/rails/generators/rails/devcontainer/templates/devcontainer/devcontainer.json.tt b/railties/lib/rails/generators/rails/devcontainer/templates/devcontainer/devcontainer.json.tt index d25907861002b..f76fbbe100cac 100644 --- a/railties/lib/rails/generators/rails/devcontainer/templates/devcontainer/devcontainer.json.tt +++ b/railties/lib/rails/generators/rails/devcontainer/templates/devcontainer/devcontainer.json.tt @@ -8,7 +8,7 @@ // Features to add to the dev container. More info: https://containers.dev/features. "features": { - <%= features.map { |key, value| "\"#{key}\": #{value.as_json}" }.join(",\n ") %> + <%= features.map { |key, value| "\"#{key}\": #{value.to_json}" }.join(",\n ") %> }, <%- if !container_env.empty? -%> diff --git a/railties/test/generators/generators_test_helper.rb b/railties/test/generators/generators_test_helper.rb index d92eb5bb7477b..6bc08c3ca0313 100644 --- a/railties/test/generators/generators_test_helper.rb +++ b/railties/test/generators/generators_test_helper.rb @@ -135,6 +135,9 @@ def assert_compose_file def assert_devcontainer_json_file assert_file ".devcontainer/devcontainer.json" do |content| yield JSON.load(content) + rescue JSON::ParserError + puts "Failed to parse JSON: #{content}" + raise end end From c79dc282d687cff1c5dc81e19d27257dca5e6a80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Mon, 15 Sep 2025 18:07:07 +0000 Subject: [PATCH 0748/1075] Remove deprecated support for `to_time` to preserve the system local time It will now always preserve the receiver timezone. --- activesupport/CHANGELOG.md | 9 + activesupport/lib/active_support.rb | 23 +- .../core_ext/date_and_time/compatibility.rb | 35 -- .../core_ext/date_time/compatibility.rb | 8 +- .../core_ext/time/compatibility.rb | 29 +- activesupport/lib/active_support/railtie.rb | 4 - .../lib/active_support/time_with_zone.rb | 8 +- activesupport/test/abstract_unit.rb | 5 - .../date_and_time_compatibility_test.rb | 435 +++--------------- .../test/core_ext/date_time_ext_test.rb | 9 +- .../test/core_ext/string_ext_test.rb | 15 +- .../test/core_ext/time_with_zone_test.rb | 86 +--- activesupport/test/time_zone_test_helpers.rb | 15 - guides/source/8_1_release_notes.md | 5 + guides/source/configuring.md | 14 - .../lib/rails/application/configuration.rb | 8 - railties/test/isolation/abstract_unit.rb | 1 - .../visitor/framework_default_test.rb | 3 - 18 files changed, 117 insertions(+), 595 deletions(-) diff --git a/activesupport/CHANGELOG.md b/activesupport/CHANGELOG.md index bf71ddcbf80ef..78c4321538aab 100644 --- a/activesupport/CHANGELOG.md +++ b/activesupport/CHANGELOG.md @@ -1,3 +1,12 @@ +* Remove deprecated support for `to_time` to preserve the system local time. It will now always preserve the receiver + timezone. + + *Rafael Mendonça França* + +* Deprecate `config.active_support.to_time_preserves_timezone`. + + *Rafael Mendonça França* + * Standardize event name formatting in `assert_event_reported` error messages. The event name in failure messages now uses `.inspect` (e.g., `name: "user.created"`) diff --git a/activesupport/lib/active_support.rb b/activesupport/lib/active_support.rb index b5939247b9344..11ee28b4b550c 100644 --- a/activesupport/lib/active_support.rb +++ b/activesupport/lib/active_support.rb @@ -126,23 +126,18 @@ def self.cache_format_version=(value) end def self.to_time_preserves_timezone - DateAndTime::Compatibility.preserve_timezone + ActiveSupport.deprecator.warn( + "`config.active_support.to_time_preserves_timezone` is deprecated and will be removed in Rails 8.2" + ) + @to_time_preserves_timezone end def self.to_time_preserves_timezone=(value) - if !value - ActiveSupport.deprecator.warn( - "`to_time` will always preserve the receiver timezone rather than system local time in Rails 8.1. " \ - "To opt in to the new behavior, set `config.active_support.to_time_preserves_timezone = :zone`." - ) - elsif value != :zone - ActiveSupport.deprecator.warn( - "`to_time` will always preserve the full timezone rather than offset of the receiver in Rails 8.1. " \ - "To opt in to the new behavior, set `config.active_support.to_time_preserves_timezone = :zone`." - ) - end - - DateAndTime::Compatibility.preserve_timezone = value + ActiveSupport.deprecator.warn( + "`config.active_support.to_time_preserves_timezone` is deprecated and will be removed in Rails 8.2" + ) + + @to_time_preserves_timezone = value end def self.utc_to_local_returns_utc_offset_times diff --git a/activesupport/lib/active_support/core_ext/date_and_time/compatibility.rb b/activesupport/lib/active_support/core_ext/date_and_time/compatibility.rb index bc0c02741d56d..4a4ef5bafae17 100644 --- a/activesupport/lib/active_support/core_ext/date_and_time/compatibility.rb +++ b/activesupport/lib/active_support/core_ext/date_and_time/compatibility.rb @@ -5,41 +5,6 @@ module DateAndTime module Compatibility - # If true, +to_time+ preserves the timezone offset of receiver. - # - # NOTE: With Ruby 2.4+ the default for +to_time+ changed from - # converting to the local system time, to preserving the offset - # of the receiver. For backwards compatibility we're overriding - # this behavior, but new apps will have an initializer that sets - # this to true, because the new behavior is preferred. - mattr_accessor :preserve_timezone, instance_accessor: false, default: nil - - singleton_class.silence_redefinition_of_method :preserve_timezone - - #-- - # This re-implements the behavior of the mattr_reader, instead - # of prepending on to it, to avoid overcomplicating a module that - # is in turn included in several places. This will all go away in - # Rails 8.0 anyway. - def self.preserve_timezone # :nodoc: - if @@preserve_timezone.nil? - # Only warn once, the first time the value is used (which should - # be the first time #to_time is called). - ActiveSupport.deprecator.warn( - "`to_time` will always preserve the receiver timezone rather than system local time in Rails 8.1." \ - "To opt in to the new behavior, set `config.active_support.to_time_preserves_timezone = :zone`." - ) - - @@preserve_timezone = false - end - - @@preserve_timezone - end - - def preserve_timezone # :nodoc: - Compatibility.preserve_timezone - end - # Change the output of ActiveSupport::TimeZone.utc_to_local. # # When +true+, it returns local times with a UTC offset, with +false+ local diff --git a/activesupport/lib/active_support/core_ext/date_time/compatibility.rb b/activesupport/lib/active_support/core_ext/date_time/compatibility.rb index 7600a067cc579..3b8d82b794935 100644 --- a/activesupport/lib/active_support/core_ext/date_time/compatibility.rb +++ b/activesupport/lib/active_support/core_ext/date_time/compatibility.rb @@ -8,11 +8,9 @@ class DateTime silence_redefinition_of_method :to_time - # Either return an instance of +Time+ with the same UTC offset - # as +self+ or an instance of +Time+ representing the same time - # in the local system timezone depending on the setting of - # on the setting of +ActiveSupport.to_time_preserves_timezone+. + # Return an instance of +Time+ with the same UTC offset + # as +self+. def to_time - preserve_timezone ? getlocal(utc_offset) : getlocal + getlocal(utc_offset) end end diff --git a/activesupport/lib/active_support/core_ext/time/compatibility.rb b/activesupport/lib/active_support/core_ext/time/compatibility.rb index 4e6c8ca3ca4dd..de5290c1a5ad9 100644 --- a/activesupport/lib/active_support/core_ext/time/compatibility.rb +++ b/activesupport/lib/active_support/core_ext/time/compatibility.rb @@ -8,33 +8,8 @@ class Time silence_redefinition_of_method :to_time - # Either return +self+ or the time in the local system timezone depending - # on the setting of +ActiveSupport.to_time_preserves_timezone+. + # Return +self+. def to_time - preserve_timezone ? self : getlocal + self end - - def preserve_timezone # :nodoc: - system_local_time? || super - end - - private - def system_local_time? - if ::Time.equal?(self.class) - zone = self.zone - String === zone && - (zone != "UTC" || active_support_local_zone == "UTC") - end - end - - @@active_support_local_tz = nil - - def active_support_local_zone - @@active_support_local_zone = nil if @@active_support_local_tz != ENV["TZ"] - @@active_support_local_zone ||= - begin - @@active_support_local_tz = ENV["TZ"] - Time.new.zone - end - end end diff --git a/activesupport/lib/active_support/railtie.rb b/activesupport/lib/active_support/railtie.rb index 3a370fef49e95..f9276b721a895 100644 --- a/activesupport/lib/active_support/railtie.rb +++ b/activesupport/lib/active_support/railtie.rb @@ -118,10 +118,6 @@ class Railtie < Rails::Railtie # :nodoc: config.eager_load_namespaces << TZInfo end - initializer "active_support.to_time_preserves_timezone" do |app| - ActiveSupport.to_time_preserves_timezone = app.config.active_support.to_time_preserves_timezone - end - # Sets the default week start # If assigned value is not a valid day symbol (e.g. :sunday, :monday, ...), an exception will be raised. initializer "active_support.initialize_beginning_of_week" do |app| diff --git a/activesupport/lib/active_support/time_with_zone.rb b/activesupport/lib/active_support/time_with_zone.rb index 1b301dc85a3c7..ec63b70a60ef8 100644 --- a/activesupport/lib/active_support/time_with_zone.rb +++ b/activesupport/lib/active_support/time_with_zone.rb @@ -503,13 +503,7 @@ def to_datetime # with the same UTC offset as +self+ or in the local system timezone # depending on the setting of +ActiveSupport.to_time_preserves_timezone+. def to_time - if preserve_timezone == :zone - @to_time_with_timezone ||= getlocal(time_zone) - elsif preserve_timezone - @to_time_with_instance_offset ||= getlocal(utc_offset) - else - @to_time_with_system_offset ||= getlocal - end + @to_time_with_timezone ||= getlocal(time_zone) end # So that +self+ acts_like?(:time). diff --git a/activesupport/test/abstract_unit.rb b/activesupport/test/abstract_unit.rb index 5aa633451e881..a03d2a99bae70 100644 --- a/activesupport/test/abstract_unit.rb +++ b/activesupport/test/abstract_unit.rb @@ -24,11 +24,6 @@ # Show backtraces for deprecated behavior for quicker cleanup. ActiveSupport.deprecator.behavior = :raise -# Default to Ruby 2.4+ to_time behavior but allow running tests with old behavior -ActiveSupport.deprecator.silence do - ActiveSupport.to_time_preserves_timezone = ENV.fetch("PRESERVE_TIMEZONES", "1") == "1" -end - ActiveSupport::Cache.format_version = 7.1 # Disable available locale checks to avoid warnings running the test suite. diff --git a/activesupport/test/core_ext/date_and_time_compatibility_test.rb b/activesupport/test/core_ext/date_and_time_compatibility_test.rb index d00f3ea34ec19..a684a94709e79 100644 --- a/activesupport/test/core_ext/date_and_time_compatibility_test.rb +++ b/activesupport/test/core_ext/date_and_time_compatibility_test.rb @@ -17,410 +17,115 @@ def setup end def test_time_to_time_preserves_timezone - with_preserve_timezone(true) do - with_env_tz "US/Eastern" do - source = Time.new(2016, 4, 23, 15, 11, 12, 3600) - time = source.to_time + with_env_tz "US/Eastern" do + source = Time.new(2016, 4, 23, 15, 11, 12, 3600) + time = source.to_time - assert_instance_of Time, time - assert_equal @utc_time, time.getutc - assert_equal @utc_offset, time.utc_offset - assert_equal source.object_id, time.object_id - end - end - end - - def test_time_to_time_does_not_preserve_time_zone - with_preserve_timezone(false) do - with_env_tz "US/Eastern" do - source = Time.new(2016, 4, 23, 15, 11, 12, 3600) - time = source.to_time - - assert_instance_of Time, time - assert_equal @utc_time, time.getutc - assert_equal @system_offset, time.utc_offset - assert_not_equal source.object_id, time.object_id - end - end - end - - def test_time_to_time_on_utc_value_without_preserve_configured - with_preserve_timezone(nil) do - with_env_tz "US/Eastern" do - source = Time.new(2016, 4, 23, 15, 11, 12) - # No warning because it's already local - base_time = source.to_time - - utc_time = base_time.getutc - converted_time = assert_deprecated(ActiveSupport.deprecator) { utc_time.to_time } - - assert_equal source, base_time - assert_equal source, converted_time - assert_equal @system_offset, base_time.utc_offset - assert_equal @system_offset, converted_time.utc_offset - end - end - - with_preserve_timezone(nil) do - with_env_tz "US/Eastern" do - source = Time.new(2016, 11, 23, 15, 11, 12) - # No warning because it's already local - base_time = source.to_time - - utc_time = base_time.getutc - converted_time = assert_deprecated(ActiveSupport.deprecator) { utc_time.to_time } - - assert_equal source, base_time - assert_equal source, converted_time - assert_equal @system_dst_offset, base_time.utc_offset - assert_equal @system_dst_offset, converted_time.utc_offset - end - end - end - - def test_time_to_time_on_offset_value_without_preserve_configured - with_preserve_timezone(nil) do - with_env_tz "US/Eastern" do - foreign_time = Time.new(2016, 4, 23, 15, 11, 12, in: "-0700") - converted_time = assert_deprecated(ActiveSupport.deprecator) { foreign_time.to_time } - - assert_equal foreign_time, converted_time - assert_equal @system_offset, converted_time.utc_offset - assert_not_equal foreign_time.utc_offset, converted_time.utc_offset - end - end - - with_preserve_timezone(nil) do - with_env_tz "US/Eastern" do - foreign_time = Time.new(2016, 11, 23, 15, 11, 12, in: "-0700") - converted_time = assert_deprecated(ActiveSupport.deprecator) { foreign_time.to_time } - - assert_equal foreign_time, converted_time - assert_equal @system_dst_offset, converted_time.utc_offset - assert_not_equal foreign_time.utc_offset, converted_time.utc_offset - end - end - end - - def test_time_to_time_on_tzinfo_value_without_preserve_configured - foreign_zone = ActiveSupport::TimeZone["America/Phoenix"] - - with_preserve_timezone(nil) do - with_env_tz "US/Eastern" do - foreign_time = foreign_zone.tzinfo.utc_to_local(Time.new(2016, 4, 23, 15, 11, 12, in: "-0700")) - converted_time = assert_deprecated(ActiveSupport.deprecator) { foreign_time.to_time } - - assert_equal foreign_time, converted_time - assert_equal @system_offset, converted_time.utc_offset - assert_not_equal foreign_time.utc_offset, converted_time.utc_offset - end - end - - with_preserve_timezone(nil) do - with_env_tz "US/Eastern" do - foreign_time = foreign_zone.tzinfo.utc_to_local(Time.new(2016, 11, 23, 15, 11, 12, in: "-0700")) - converted_time = assert_deprecated(ActiveSupport.deprecator) { foreign_time.to_time } - - assert_equal foreign_time, converted_time - assert_equal @system_dst_offset, converted_time.utc_offset - assert_not_equal foreign_time.utc_offset, converted_time.utc_offset - end + assert_instance_of Time, time + assert_equal @utc_time, time.getutc + assert_equal @utc_offset, time.utc_offset + assert_equal source.object_id, time.object_id end end def test_time_to_time_frozen_preserves_timezone - with_preserve_timezone(true) do - with_env_tz "US/Eastern" do - source = Time.new(2016, 4, 23, 15, 11, 12, 3600).freeze - time = source.to_time - - assert_instance_of Time, time - assert_equal @utc_time, time.getutc - assert_equal @utc_offset, time.utc_offset - assert_equal source.object_id, time.object_id - assert_predicate time, :frozen? - end - end - end - - def test_time_to_time_frozen_does_not_preserve_time_zone - with_preserve_timezone(false) do - with_env_tz "US/Eastern" do - source = Time.new(2016, 4, 23, 15, 11, 12, 3600).freeze - time = source.to_time + with_env_tz "US/Eastern" do + source = Time.new(2016, 4, 23, 15, 11, 12, 3600).freeze + time = source.to_time - assert_instance_of Time, time - assert_equal @utc_time, time.getutc - assert_equal @system_offset, time.utc_offset - assert_not_equal source.object_id, time.object_id - assert_not_predicate time, :frozen? - end + assert_instance_of Time, time + assert_equal @utc_time, time.getutc + assert_equal @utc_offset, time.utc_offset + assert_equal source.object_id, time.object_id + assert_predicate time, :frozen? end end def test_datetime_to_time_preserves_timezone - with_preserve_timezone(true) do - with_env_tz "US/Eastern" do - source = DateTime.new(2016, 4, 23, 15, 11, 12, Rational(1, 24)) - time = source.to_time + with_env_tz "US/Eastern" do + source = DateTime.new(2016, 4, 23, 15, 11, 12, Rational(1, 24)) + time = source.to_time - assert_instance_of Time, time - assert_equal @utc_time, time.getutc - assert_equal @utc_offset, time.utc_offset - end - end - end - - def test_datetime_to_time_does_not_preserve_time_zone - with_preserve_timezone(false) do - with_env_tz "US/Eastern" do - source = DateTime.new(2016, 4, 23, 15, 11, 12, Rational(1, 24)) - time = source.to_time - - assert_instance_of Time, time - assert_equal @utc_time, time.getutc - assert_equal @system_offset, time.utc_offset - end + assert_instance_of Time, time + assert_equal @utc_time, time.getutc + assert_equal @utc_offset, time.utc_offset end end def test_datetime_to_time_frozen_preserves_timezone - with_preserve_timezone(true) do - with_env_tz "US/Eastern" do - source = DateTime.new(2016, 4, 23, 15, 11, 12, Rational(1, 24)).freeze - time = source.to_time - - assert_instance_of Time, time - assert_equal @utc_time, time.getutc - assert_equal @utc_offset, time.utc_offset - assert_not_predicate time, :frozen? - end - end - end - - def test_datetime_to_time_frozen_does_not_preserve_time_zone - with_preserve_timezone(false) do - with_env_tz "US/Eastern" do - source = DateTime.new(2016, 4, 23, 15, 11, 12, Rational(1, 24)).freeze - time = source.to_time + with_env_tz "US/Eastern" do + source = DateTime.new(2016, 4, 23, 15, 11, 12, Rational(1, 24)).freeze + time = source.to_time - assert_instance_of Time, time - assert_equal @utc_time, time.getutc - assert_equal @system_offset, time.utc_offset - assert_not_predicate time, :frozen? - end + assert_instance_of Time, time + assert_equal @utc_time, time.getutc + assert_equal @utc_offset, time.utc_offset + assert_not_predicate time, :frozen? end end def test_twz_to_time_preserves_timezone - with_preserve_timezone(true) do - with_env_tz "US/Eastern" do - source = ActiveSupport::TimeWithZone.new(@utc_time, @zone) - time = source.to_time - - assert_instance_of Time, time - assert_equal @utc_time, time.getutc - assert_instance_of Time, time.getutc - assert_equal @utc_offset, time.utc_offset - - source = ActiveSupport::TimeWithZone.new(@date_time, @zone) - time = source.to_time - - assert_instance_of Time, time - assert_equal @date_time, time.getutc - assert_instance_of Time, time.getutc - assert_equal @utc_offset, time.utc_offset - end - end - end - - def test_twz_to_time_does_not_preserve_time_zone - with_preserve_timezone(false) do - with_env_tz "US/Eastern" do - source = ActiveSupport::TimeWithZone.new(@utc_time, @zone) - time = source.to_time + with_env_tz "US/Eastern" do + source = ActiveSupport::TimeWithZone.new(@utc_time, @zone) + time = source.to_time - assert_instance_of Time, time - assert_equal @utc_time, time.getutc - assert_instance_of Time, time.getutc - assert_equal @system_offset, time.utc_offset + assert_instance_of Time, time + assert_equal @utc_time, time.getutc + assert_instance_of Time, time.getutc + assert_equal @utc_offset, time.utc_offset - source = ActiveSupport::TimeWithZone.new(@date_time, @zone) - time = source.to_time + source = ActiveSupport::TimeWithZone.new(@date_time, @zone) + time = source.to_time - assert_instance_of Time, time - assert_equal @date_time, time.getutc - assert_instance_of Time, time.getutc - assert_equal @system_offset, time.utc_offset - end + assert_instance_of Time, time + assert_equal @date_time, time.getutc + assert_instance_of Time, time.getutc + assert_equal @utc_offset, time.utc_offset end end def test_twz_to_time_frozen_preserves_timezone - with_preserve_timezone(true) do - with_env_tz "US/Eastern" do - source = ActiveSupport::TimeWithZone.new(@utc_time, @zone).freeze - time = source.to_time + with_env_tz "US/Eastern" do + source = ActiveSupport::TimeWithZone.new(@utc_time, @zone).freeze + time = source.to_time - assert_instance_of Time, time - assert_equal @utc_time, time.getutc - assert_instance_of Time, time.getutc - assert_equal @utc_offset, time.utc_offset - assert_not_predicate time, :frozen? + assert_instance_of Time, time + assert_equal @utc_time, time.getutc + assert_instance_of Time, time.getutc + assert_equal @utc_offset, time.utc_offset + assert_not_predicate time, :frozen? - source = ActiveSupport::TimeWithZone.new(@date_time, @zone).freeze - time = source.to_time + source = ActiveSupport::TimeWithZone.new(@date_time, @zone).freeze + time = source.to_time - assert_instance_of Time, time - assert_equal @date_time, time.getutc - assert_instance_of Time, time.getutc - assert_equal @utc_offset, time.utc_offset - assert_not_predicate time, :frozen? - end - end - end - - def test_twz_to_time_frozen_does_not_preserve_time_zone - with_preserve_timezone(false) do - with_env_tz "US/Eastern" do - source = ActiveSupport::TimeWithZone.new(@utc_time, @zone).freeze - time = source.to_time - - assert_instance_of Time, time - assert_equal @utc_time, time.getutc - assert_instance_of Time, time.getutc - assert_equal @system_offset, time.utc_offset - assert_not_predicate time, :frozen? - - source = ActiveSupport::TimeWithZone.new(@date_time, @zone).freeze - time = source.to_time - - assert_instance_of Time, time - assert_equal @date_time, time.getutc - assert_instance_of Time, time.getutc - assert_equal @system_offset, time.utc_offset - assert_not_predicate time, :frozen? - end + assert_instance_of Time, time + assert_equal @date_time, time.getutc + assert_instance_of Time, time.getutc + assert_equal @utc_offset, time.utc_offset + assert_not_predicate time, :frozen? end end def test_string_to_time_preserves_timezone - with_preserve_timezone(true) do - with_env_tz "US/Eastern" do - source = "2016-04-23T15:11:12+01:00" - time = source.to_time + with_env_tz "US/Eastern" do + source = "2016-04-23T15:11:12+01:00" + time = source.to_time - assert_instance_of Time, time - assert_equal @utc_time, time.getutc - assert_equal @utc_offset, time.utc_offset - end - end - end - - def test_string_to_time_does_not_preserve_time_zone - with_preserve_timezone(false) do - with_env_tz "US/Eastern" do - source = "2016-04-23T15:11:12+01:00" - time = source.to_time - - assert_instance_of Time, time - assert_equal @utc_time, time.getutc - assert_equal @system_offset, time.utc_offset - end + assert_instance_of Time, time + assert_equal @utc_time, time.getutc + assert_equal @utc_offset, time.utc_offset end end def test_string_to_time_frozen_preserves_timezone - with_preserve_timezone(true) do - with_env_tz "US/Eastern" do - source = "2016-04-23T15:11:12+01:00" - time = source.to_time - - assert_instance_of Time, time - assert_equal @utc_time, time.getutc - assert_equal @utc_offset, time.utc_offset - assert_not_predicate time, :frozen? - end - end - end - - def test_string_to_time_frozen_does_not_preserve_time_zone - with_preserve_timezone(false) do - with_env_tz "US/Eastern" do - source = "2016-04-23T15:11:12+01:00" - time = source.to_time - - assert_instance_of Time, time - assert_equal @utc_time, time.getutc - assert_equal @system_offset, time.utc_offset - assert_not_predicate time, :frozen? - end - end - end - - def test_to_time_preserves_timezone_is_deprecated - current_preserve_tz = ActiveSupport.to_time_preserves_timezone - - assert_not_deprecated(ActiveSupport.deprecator) do - ActiveSupport.to_time_preserves_timezone - end - - assert_deprecated(ActiveSupport.deprecator) do - ActiveSupport.to_time_preserves_timezone = :offset - end - - assert_deprecated(ActiveSupport.deprecator) do - ActiveSupport.to_time_preserves_timezone = false - end - - assert_deprecated(ActiveSupport.deprecator) do - ActiveSupport.to_time_preserves_timezone = nil - end - - # When set to nil, the first call will report a deprecation, - # then switch the configured value to (and return) false. - assert_deprecated(ActiveSupport.deprecator) do - assert_equal false, ActiveSupport.to_time_preserves_timezone - end - - assert_not_deprecated(ActiveSupport.deprecator) do - ActiveSupport.to_time_preserves_timezone - end - ensure - ActiveSupport.deprecator.silence do - ActiveSupport.to_time_preserves_timezone = current_preserve_tz - end - end - - def test_to_time_preserves_timezone_supports_new_values - current_preserve_tz = ActiveSupport.to_time_preserves_timezone - - assert_not_deprecated(ActiveSupport.deprecator) do - ActiveSupport.to_time_preserves_timezone - end - - assert_not_deprecated(ActiveSupport.deprecator) do - ActiveSupport.to_time_preserves_timezone = :zone - end - - assert_deprecated(ActiveSupport.deprecator) do - ActiveSupport.to_time_preserves_timezone = :offset - end - - assert_deprecated(ActiveSupport.deprecator) do - ActiveSupport.to_time_preserves_timezone = true - end - - assert_deprecated(ActiveSupport.deprecator) do - ActiveSupport.to_time_preserves_timezone = "offset" - end - - assert_deprecated(ActiveSupport.deprecator) do - ActiveSupport.to_time_preserves_timezone = :foo - end - ensure - ActiveSupport.deprecator.silence do - ActiveSupport.to_time_preserves_timezone = current_preserve_tz + with_env_tz "US/Eastern" do + source = "2016-04-23T15:11:12+01:00" + time = source.to_time + + assert_instance_of Time, time + assert_equal @utc_time, time.getutc + assert_equal @utc_offset, time.utc_offset + assert_not_predicate time, :frozen? end end end diff --git a/activesupport/test/core_ext/date_time_ext_test.rb b/activesupport/test/core_ext/date_time_ext_test.rb index 320b683686379..bbff9a515395e 100644 --- a/activesupport/test/core_ext/date_time_ext_test.rb +++ b/activesupport/test/core_ext/date_time_ext_test.rb @@ -78,13 +78,8 @@ def test_to_time with_env_tz "US/Eastern" do assert_instance_of Time, DateTime.new(2005, 2, 21, 10, 11, 12, 0).to_time - if ActiveSupport.to_time_preserves_timezone - assert_equal Time.local(2005, 2, 21, 5, 11, 12).getlocal(0), DateTime.new(2005, 2, 21, 10, 11, 12, 0).to_time - assert_equal Time.local(2005, 2, 21, 5, 11, 12).getlocal(0).utc_offset, DateTime.new(2005, 2, 21, 10, 11, 12, 0).to_time.utc_offset - else - assert_equal Time.local(2005, 2, 21, 5, 11, 12), DateTime.new(2005, 2, 21, 10, 11, 12, 0).to_time - assert_equal Time.local(2005, 2, 21, 5, 11, 12).utc_offset, DateTime.new(2005, 2, 21, 10, 11, 12, 0).to_time.utc_offset - end + assert_equal Time.local(2005, 2, 21, 5, 11, 12).getlocal(0), DateTime.new(2005, 2, 21, 10, 11, 12, 0).to_time + assert_equal Time.local(2005, 2, 21, 5, 11, 12).getlocal(0).utc_offset, DateTime.new(2005, 2, 21, 10, 11, 12, 0).to_time.utc_offset end end diff --git a/activesupport/test/core_ext/string_ext_test.rb b/activesupport/test/core_ext/string_ext_test.rb index 1a490a832b8de..a085918fc38c4 100644 --- a/activesupport/test/core_ext/string_ext_test.rb +++ b/activesupport/test/core_ext/string_ext_test.rb @@ -611,17 +611,10 @@ def test_timestamp_string_to_time def test_string_to_time_utc_offset with_env_tz "US/Eastern" do - if ActiveSupport.to_time_preserves_timezone - assert_equal 0, "2005-02-27 23:50".to_time(:utc).utc_offset - assert_equal(-18000, "2005-02-27 23:50".to_time.utc_offset) - assert_equal 0, "2005-02-27 22:50 -0100".to_time(:utc).utc_offset - assert_equal(-3600, "2005-02-27 22:50 -0100".to_time.utc_offset) - else - assert_equal 0, "2005-02-27 23:50".to_time(:utc).utc_offset - assert_equal(-18000, "2005-02-27 23:50".to_time.utc_offset) - assert_equal 0, "2005-02-27 22:50 -0100".to_time(:utc).utc_offset - assert_equal(-18000, "2005-02-27 22:50 -0100".to_time.utc_offset) - end + assert_equal 0, "2005-02-27 23:50".to_time(:utc).utc_offset + assert_equal(-18000, "2005-02-27 23:50".to_time.utc_offset) + assert_equal 0, "2005-02-27 22:50 -0100".to_time(:utc).utc_offset + assert_equal(-3600, "2005-02-27 22:50 -0100".to_time.utc_offset) 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 d413b6aab8926..460e31d33410f 100644 --- a/activesupport/test/core_ext/time_with_zone_test.rb +++ b/activesupport/test/core_ext/time_with_zone_test.rb @@ -451,13 +451,11 @@ def test_minus_with_time_with_zone end def test_minus_with_time_with_zone_without_preserve_configured - with_preserve_timezone(nil) do - twz1 = ActiveSupport::TimeWithZone.new(Time.utc(2000, 1, 1), ActiveSupport::TimeZone["UTC"]) - twz2 = ActiveSupport::TimeWithZone.new(Time.utc(2000, 1, 2), ActiveSupport::TimeZone["UTC"]) + twz1 = ActiveSupport::TimeWithZone.new(Time.utc(2000, 1, 1), ActiveSupport::TimeZone["UTC"]) + twz2 = ActiveSupport::TimeWithZone.new(Time.utc(2000, 1, 2), ActiveSupport::TimeZone["UTC"]) - difference = assert_not_deprecated(ActiveSupport.deprecator) { twz2 - twz1 } - assert_equal 86_400.0, difference - end + difference = assert_not_deprecated(ActiveSupport.deprecator) { twz2 - twz1 } + assert_equal 86_400.0, difference end def test_minus_with_time_with_zone_precision @@ -544,75 +542,15 @@ def test_time_at assert_equal time, Time.at(time) end - def test_to_time_with_preserve_timezone_using_zone - with_preserve_timezone(:zone) do - time = @twz.to_time - local_time = with_env_tz("US/Eastern") { Time.local(1999, 12, 31, 19) } - - assert_equal Time, time.class - assert_equal time.object_id, @twz.to_time.object_id - assert_equal local_time, time - assert_equal local_time.utc_offset, time.utc_offset - assert_equal @time_zone, time.zone - end - end - - def test_to_time_with_preserve_timezone_using_offset - with_preserve_timezone(:offset) do - with_env_tz "US/Eastern" do - time = @twz.to_time + def test_to_time_preserve_timezone + time = @twz.to_time + local_time = with_env_tz("US/Eastern") { Time.local(1999, 12, 31, 19) } - assert_equal Time, time.class - assert_equal time.object_id, @twz.to_time.object_id - assert_equal Time.local(1999, 12, 31, 19), time - assert_equal Time.local(1999, 12, 31, 19).utc_offset, time.utc_offset - assert_nil time.zone - end - end - end - - def test_to_time_with_preserve_timezone_using_true - with_preserve_timezone(true) do - with_env_tz "US/Eastern" do - time = @twz.to_time - - assert_equal Time, time.class - assert_equal time.object_id, @twz.to_time.object_id - assert_equal Time.local(1999, 12, 31, 19), time - assert_equal Time.local(1999, 12, 31, 19).utc_offset, time.utc_offset - assert_nil time.zone - end - end - end - - def test_to_time_without_preserve_timezone - with_preserve_timezone(false) do - with_env_tz "US/Eastern" do - time = @twz.to_time - - assert_equal Time, time.class - assert_equal time.object_id, @twz.to_time.object_id - assert_equal Time.local(1999, 12, 31, 19), time - assert_equal Time.local(1999, 12, 31, 19).utc_offset, time.utc_offset - assert_equal Time.local(1999, 12, 31, 19).zone, time.zone - end - end - end - - def test_to_time_without_preserve_timezone_configured - with_preserve_timezone(nil) do - with_env_tz "US/Eastern" do - time = assert_deprecated(ActiveSupport.deprecator) { @twz.to_time } - - assert_equal Time, time.class - assert_equal time.object_id, @twz.to_time.object_id - assert_equal Time.local(1999, 12, 31, 19), time - assert_equal Time.local(1999, 12, 31, 19).utc_offset, time.utc_offset - assert_equal Time.local(1999, 12, 31, 19).zone, time.zone - - assert_equal false, ActiveSupport.to_time_preserves_timezone - end - end + assert_equal Time, time.class + assert_equal time.object_id, @twz.to_time.object_id + assert_equal local_time, time + assert_equal local_time.utc_offset, time.utc_offset + assert_equal @time_zone, time.zone end def test_to_date diff --git a/activesupport/test/time_zone_test_helpers.rb b/activesupport/test/time_zone_test_helpers.rb index 312d5a2c9a95e..f9efbe22d76ad 100644 --- a/activesupport/test/time_zone_test_helpers.rb +++ b/activesupport/test/time_zone_test_helpers.rb @@ -15,21 +15,6 @@ def with_env_tz(new_tz = "US/Eastern") ensure old_tz ? ENV["TZ"] = old_tz : ENV.delete("TZ") end - - def with_preserve_timezone(value) - old_preserve_tz = ActiveSupport.to_time_preserves_timezone - - ActiveSupport.deprecator.silence do - ActiveSupport.to_time_preserves_timezone = value - end - - yield - ensure - ActiveSupport.deprecator.silence do - ActiveSupport.to_time_preserves_timezone = old_preserve_tz - end - end - def with_tz_mappings(mappings) old_mappings = ActiveSupport::TimeZone::MAPPING.dup ActiveSupport::TimeZone.clear diff --git a/guides/source/8_1_release_notes.md b/guides/source/8_1_release_notes.md index 29582275f5590..bed1b83efedd3 100644 --- a/guides/source/8_1_release_notes.md +++ b/guides/source/8_1_release_notes.md @@ -160,8 +160,13 @@ Please refer to the [Changelog][active-support] for detailed changes. ### Removals +* Remove deprecated support for `to_time` to preserve the system local time. It will now always preserve the receiver + timezone. + ### Deprecations +* Deprecate `config.active_support.to_time_preserves_timezone`. + ### Notable changes Active Job diff --git a/guides/source/configuring.md b/guides/source/configuring.md index 4f8cfa3d5feb5..7b92f6206ee44 100644 --- a/guides/source/configuring.md +++ b/guides/source/configuring.md @@ -72,7 +72,6 @@ Below are the default values associated with each target version. In cases of co - [`Regexp.timeout`](#regexp-timeout): `1` - [`config.action_dispatch.strict_freshness`](#config-action-dispatch-strict-freshness): `true` -- [`config.active_support.to_time_preserves_timezone`](#config-active-support-to-time-preserves-timezone): `:zone` #### Default Values for Target Version 7.2 @@ -172,7 +171,6 @@ Below are the default values associated with each target version. In cases of co - [`config.action_controller.forgery_protection_origin_check`](#config-action-controller-forgery-protection-origin-check): `true` - [`config.action_controller.per_form_csrf_tokens`](#config-action-controller-per-form-csrf-tokens): `true` - [`config.active_record.belongs_to_required_by_default`](#config-active-record-belongs-to-required-by-default): `true` -- [`config.active_support.to_time_preserves_timezone`](#config-active-support-to-time-preserves-timezone): `:offset` - [`config.ssl_options`](#config-ssl-options): `{ hsts: { subdomains: true } }` ### Rails General Configuration @@ -2937,18 +2935,6 @@ The default value depends on the `config.load_defaults` target version: | (original) | `false` | | 7.0 | `true` | -#### `config.active_support.to_time_preserves_timezone` - -Specifies whether `to_time` methods preserve the UTC offset of their receivers or preserves the timezone. If set to `:zone`, `to_time` methods will use the timezone of their receivers. If set to `:offset`, `to_time` methods will use the UTC offset. If `false`, `to_time` methods will convert to the local system UTC offset instead. - -The default value depends on the `config.load_defaults` target version: - -| Starting with version | The default value is | -| --------------------- | -------------------- | -| (original) | `false` | -| 5.0 | `:offset` | -| 8.0 | `:zone` | - #### `ActiveSupport::Logger.silencer` Is set to `false` to disable the ability to silence logging in a block. The default is `true`. diff --git a/railties/lib/rails/application/configuration.rb b/railties/lib/rails/application/configuration.rb index c982afafb2a1a..285ba0aa63170 100644 --- a/railties/lib/rails/application/configuration.rb +++ b/railties/lib/rails/application/configuration.rb @@ -117,10 +117,6 @@ def load_defaults(target_version) action_controller.forgery_protection_origin_check = true end - if respond_to?(:active_support) - active_support.to_time_preserves_timezone = :offset - end - if respond_to?(:active_record) active_record.belongs_to_required_by_default = true end @@ -339,10 +335,6 @@ def load_defaults(target_version) when "8.0" load_defaults "7.2" - if respond_to?(:active_support) - active_support.to_time_preserves_timezone = :zone - end - if respond_to?(:action_dispatch) action_dispatch.strict_freshness = true end diff --git a/railties/test/isolation/abstract_unit.rb b/railties/test/isolation/abstract_unit.rb index d7208a0ca9a66..a1913ad497e64 100644 --- a/railties/test/isolation/abstract_unit.rb +++ b/railties/test/isolation/abstract_unit.rb @@ -263,7 +263,6 @@ def self.name; "RailtiesTestApp"; end @app.config.active_support.deprecation = :log @app.config.log_level = :error @app.config.secret_key_base = "b3c631c314c0bbca50c1b2843150fe33" - @app.config.active_support.to_time_preserves_timezone = :zone yield @app if block_given? @app.initialize! diff --git a/tools/rail_inspector/test/rail_inspector/visitor/framework_default_test.rb b/tools/rail_inspector/test/rail_inspector/visitor/framework_default_test.rb index ff7ef3cbd95ec..77e46824e4970 100644 --- a/tools/rail_inspector/test/rail_inspector/visitor/framework_default_test.rb +++ b/tools/rail_inspector/test/rail_inspector/visitor/framework_default_test.rb @@ -8,8 +8,6 @@ def test_smoke config = config_for_defaults <<~RUBY case target_version.to_s when "5.0" - ActiveSupport.to_time_preserves_timezone = true - if respond_to?(:active_record) active_record.belongs_to_required_by_default = true end @@ -28,7 +26,6 @@ def test_smoke ["5.0", "5.1"].each { |k| assert_includes(config, k) } - assert_equal("true", config["5.0"]["ActiveSupport.to_time_preserves_timezone"]) assert_equal("true", config["5.0"]["active_record.belongs_to_required_by_default"]) assert_equal("{ hsts: { subdomains: true } }", config["5.0"]["self.ssl_options"]) assert_equal("false", config["5.1"]["assets.unknown_asset_fallback"]) From 93d370c722898c5fadfaf8fa87dc434d4a3fea59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Thu, 9 Oct 2025 23:05:41 +0000 Subject: [PATCH 0749/1075] Remove deprecated addition for `Time` instances with `ActiveSupport::TimeWithZone` --- activesupport/CHANGELOG.md | 4 ++++ activesupport/lib/active_support/time_with_zone.rb | 12 ++---------- activesupport/test/core_ext/time_with_zone_test.rb | 7 ------- guides/source/8_1_release_notes.md | 2 ++ 4 files changed, 8 insertions(+), 17 deletions(-) diff --git a/activesupport/CHANGELOG.md b/activesupport/CHANGELOG.md index 78c4321538aab..51c6509a2f853 100644 --- a/activesupport/CHANGELOG.md +++ b/activesupport/CHANGELOG.md @@ -1,3 +1,7 @@ +* Remove deprecated addition for `Time` instances with `ActiveSupport::TimeWithZone`. + + *Rafael Mendonça França* + * Remove deprecated support for `to_time` to preserve the system local time. It will now always preserve the receiver timezone. diff --git a/activesupport/lib/active_support/time_with_zone.rb b/activesupport/lib/active_support/time_with_zone.rb index ec63b70a60ef8..3001b00e1cec1 100644 --- a/activesupport/lib/active_support/time_with_zone.rb +++ b/activesupport/lib/active_support/time_with_zone.rb @@ -311,16 +311,8 @@ def +(other) if duration_of_variable_length?(other) method_missing(:+, other) else - begin - result = utc + other - rescue TypeError - result = utc.to_datetime.since(other) - ActiveSupport.deprecator.warn( - "Adding an instance of #{other.class} to an instance of #{self.class} is deprecated. This behavior will raise " \ - "a `TypeError` in Rails 8.1." - ) - result.in_time_zone(time_zone) - end + result = utc + other + result.in_time_zone(time_zone) 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 460e31d33410f..20c06cd050841 100644 --- a/activesupport/test/core_ext/time_with_zone_test.rb +++ b/activesupport/test/core_ext/time_with_zone_test.rb @@ -400,13 +400,6 @@ def test_no_limit_on_times assert_equal [0, 0, 19, 31, 12, -8001], (twz - 10_000.years).to_a[0, 6] end - def test_plus_two_time_instances_raises_deprecation_warning - twz = ActiveSupport::TimeWithZone.new(Time.utc(2000, 1, 1), @time_zone) - assert_deprecated(ActiveSupport.deprecator) do - twz + 10.days.ago - end - end - def test_plus_with_invalid_argument twz = ActiveSupport::TimeWithZone.new(Time.utc(2000, 1, 1), @time_zone) assert_not_deprecated(ActiveSupport.deprecator) do diff --git a/guides/source/8_1_release_notes.md b/guides/source/8_1_release_notes.md index bed1b83efedd3..7cc10574b583d 100644 --- a/guides/source/8_1_release_notes.md +++ b/guides/source/8_1_release_notes.md @@ -160,6 +160,8 @@ Please refer to the [Changelog][active-support] for detailed changes. ### Removals +* Remove deprecated addition for `Time` instances with `ActiveSupport::TimeWithZone`. + * Remove deprecated support for `to_time` to preserve the system local time. It will now always preserve the receiver timezone. From 632b2c5128581731c2451459081176a43f474f74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Thu, 9 Oct 2025 23:15:32 +0000 Subject: [PATCH 0750/1075] Remove deprecated `Benchmark.ms` method. It is now defined in the `benchmark` gem --- Gemfile.lock | 9 ++++----- activesupport/CHANGELOG.md | 4 ++++ activesupport/activesupport.gemspec | 1 - activesupport/lib/active_support/core_ext.rb | 2 +- .../lib/active_support/core_ext/benchmark.rb | 16 ++++------------ activesupport/test/core_ext/benchmark_test.rb | 12 ------------ guides/source/8_1_release_notes.md | 2 ++ 7 files changed, 15 insertions(+), 31 deletions(-) delete mode 100644 activesupport/test/core_ext/benchmark_test.rb diff --git a/Gemfile.lock b/Gemfile.lock index cfde6937897d9..7a4c7e53ed2db 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -62,7 +62,6 @@ PATH marcel (~> 1.0) activesupport (8.1.0.beta1) base64 - benchmark (>= 0.3) bigdecimal concurrent-ruby (~> 1.0, >= 1.3.1) connection_pool (>= 2.2.5) @@ -145,7 +144,6 @@ GEM bcrypt (3.1.20) bcrypt_pbkdf (1.1.1) beaneater (1.1.3) - benchmark (0.4.1) bigdecimal (3.2.3) bindex (0.8.1) bootsnap (1.18.4) @@ -294,8 +292,8 @@ GEM mutex_m i18n (1.14.7) concurrent-ruby (~> 1.0) - image_processing (1.13.0) - mini_magick (>= 4.9.5, < 5) + image_processing (1.14.0) + mini_magick (>= 4.9.5, < 6) ruby-vips (>= 2.0.17, < 3) importmap-rails (2.1.0) actionpack (>= 6.0.0) @@ -356,7 +354,8 @@ GEM mixlib-cli (~> 2.1, >= 2.1.1) mixlib-config (>= 2.2.1, < 4) mixlib-shellout - mini_magick (4.13.2) + mini_magick (5.3.1) + logger mini_mime (1.1.5) mini_portile2 (2.8.9) minitest (5.25.5) diff --git a/activesupport/CHANGELOG.md b/activesupport/CHANGELOG.md index 51c6509a2f853..1bbf81494718e 100644 --- a/activesupport/CHANGELOG.md +++ b/activesupport/CHANGELOG.md @@ -1,3 +1,7 @@ +* Remove deprecated `Benchmark.ms` method. It is now defined in the `benchmark` gem. + + *Rafael Mendonça França* + * Remove deprecated addition for `Time` instances with `ActiveSupport::TimeWithZone`. *Rafael Mendonça França* diff --git a/activesupport/activesupport.gemspec b/activesupport/activesupport.gemspec index 65931e3280721..8aac4535db0e1 100644 --- a/activesupport/activesupport.gemspec +++ b/activesupport/activesupport.gemspec @@ -46,5 +46,4 @@ Gem::Specification.new do |s| s.add_dependency "logger", ">= 1.4.2" s.add_dependency "securerandom", ">= 0.3" s.add_dependency "uri", ">= 0.13.1" - s.add_dependency "benchmark", ">= 0.3" end diff --git a/activesupport/lib/active_support/core_ext.rb b/activesupport/lib/active_support/core_ext.rb index 3f5d08186e28a..2ed62bfa0f8e1 100644 --- a/activesupport/lib/active_support/core_ext.rb +++ b/activesupport/lib/active_support/core_ext.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true -Dir.glob(File.expand_path("core_ext/*.rb", __dir__)).sort.each do |path| +(Dir.glob(File.expand_path("core_ext/*.rb", __dir__)).sort - [File.expand_path("core_ext/benchmark.rb", __dir__)]).each do |path| require path end diff --git a/activesupport/lib/active_support/core_ext/benchmark.rb b/activesupport/lib/active_support/core_ext/benchmark.rb index b5469e3328f1b..a3864671460ee 100644 --- a/activesupport/lib/active_support/core_ext/benchmark.rb +++ b/activesupport/lib/active_support/core_ext/benchmark.rb @@ -1,14 +1,6 @@ # frozen_string_literal: true -require "benchmark" -return if Benchmark.respond_to?(:ms) - -class << Benchmark - def ms(&block) # :nodoc - # NOTE: Please also remove the Active Support `benchmark` dependency when removing this - ActiveSupport.deprecator.warn <<~TEXT - `Benchmark.ms` is deprecated and will be removed in Rails 8.1 without replacement. - TEXT - ActiveSupport::Benchmark.realtime(:float_millisecond, &block) - end -end +# Remove this file from activesupport/lib/active_support/core_ext.rb when deleting the deprecation. +ActiveSupport.deprecator.warn <<~TEXT + active_support/core_ext/benchmark.rb is deprecated and will be removed in Rails 8.2 without replacement. +TEXT diff --git a/activesupport/test/core_ext/benchmark_test.rb b/activesupport/test/core_ext/benchmark_test.rb deleted file mode 100644 index a662dd758d189..0000000000000 --- a/activesupport/test/core_ext/benchmark_test.rb +++ /dev/null @@ -1,12 +0,0 @@ -# frozen_string_literal: true - -require_relative "../abstract_unit" -require "active_support/core_ext/benchmark" - -class BenchmarkTest < ActiveSupport::TestCase - def test_is_deprecated - assert_deprecated(ActiveSupport.deprecator) do - assert_kind_of Numeric, Benchmark.ms { } - end - end -end diff --git a/guides/source/8_1_release_notes.md b/guides/source/8_1_release_notes.md index 7cc10574b583d..b5da371cf4ea9 100644 --- a/guides/source/8_1_release_notes.md +++ b/guides/source/8_1_release_notes.md @@ -160,6 +160,8 @@ Please refer to the [Changelog][active-support] for detailed changes. ### Removals +* Remove deprecated `Benchmark.ms` method. It is now defined in the `benchmark` gem. + * Remove deprecated addition for `Time` instances with `ActiveSupport::TimeWithZone`. * Remove deprecated support for `to_time` to preserve the system local time. It will now always preserve the receiver From a855714642a1e3b3740cf8608c72fa7a6177f107 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Thu, 9 Oct 2025 23:23:26 +0000 Subject: [PATCH 0751/1075] Remove deprecated passing a Time object to `Time#since` --- activesupport/CHANGELOG.md | 4 ++++ .../lib/active_support/core_ext/time/calculations.rb | 7 ------- activesupport/test/core_ext/time_ext_test.rb | 6 ------ guides/source/8_1_release_notes.md | 2 ++ 4 files changed, 6 insertions(+), 13 deletions(-) diff --git a/activesupport/CHANGELOG.md b/activesupport/CHANGELOG.md index 1bbf81494718e..103c2b9890a27 100644 --- a/activesupport/CHANGELOG.md +++ b/activesupport/CHANGELOG.md @@ -1,3 +1,7 @@ +* Remove deprecated passing a Time object to `Time#since`. + + *Rafael Mendonça França* + * Remove deprecated `Benchmark.ms` method. It is now defined in the `benchmark` gem. *Rafael Mendonça França* diff --git a/activesupport/lib/active_support/core_ext/time/calculations.rb b/activesupport/lib/active_support/core_ext/time/calculations.rb index 471a56c9ba891..9e9a1c0af3b7d 100644 --- a/activesupport/lib/active_support/core_ext/time/calculations.rb +++ b/activesupport/lib/active_support/core_ext/time/calculations.rb @@ -224,13 +224,6 @@ def ago(seconds) # Returns a new Time representing the time a number of seconds since the instance time def since(seconds) self + seconds - rescue TypeError - result = to_datetime.since(seconds) - ActiveSupport.deprecator.warn( - "Passing an instance of #{seconds.class} to #{self.class}#since is deprecated. This behavior will raise " \ - "a `TypeError` in Rails 8.1." - ) - result end alias :in :since diff --git a/activesupport/test/core_ext/time_ext_test.rb b/activesupport/test/core_ext/time_ext_test.rb index 681d4b148d46c..7d4cb4c95633b 100644 --- a/activesupport/test/core_ext/time_ext_test.rb +++ b/activesupport/test/core_ext/time_ext_test.rb @@ -282,12 +282,6 @@ def test_daylight_savings_time_crossings_backward_end_1day end end - def test_since_with_instance_of_time_deprecated - assert_deprecated(ActiveSupport.deprecator) do - Time.now.since(Time.now) - end - end - def test_since assert_equal Time.local(2005, 2, 22, 10, 10, 11), Time.local(2005, 2, 22, 10, 10, 10).since(1) assert_equal Time.local(2005, 2, 22, 11, 10, 10), Time.local(2005, 2, 22, 10, 10, 10).since(3600) diff --git a/guides/source/8_1_release_notes.md b/guides/source/8_1_release_notes.md index b5da371cf4ea9..01da7baa6463b 100644 --- a/guides/source/8_1_release_notes.md +++ b/guides/source/8_1_release_notes.md @@ -160,6 +160,8 @@ Please refer to the [Changelog][active-support] for detailed changes. ### Removals +* Remove deprecated passing a Time object to `Time#since`. + * Remove deprecated `Benchmark.ms` method. It is now defined in the `benchmark` gem. * Remove deprecated addition for `Time` instances with `ActiveSupport::TimeWithZone`. From 9fc9e118fe796dcb26ff383eaa0876d1f351fb7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Thu, 9 Oct 2025 23:35:50 +0000 Subject: [PATCH 0752/1075] Remove deprecated `rails/console/methods.rb` file --- guides/source/8_1_release_notes.md | 2 ++ railties/CHANGELOG.md | 4 ++++ railties/lib/rails/console/methods.rb | 7 ------- 3 files changed, 6 insertions(+), 7 deletions(-) delete mode 100644 railties/lib/rails/console/methods.rb diff --git a/guides/source/8_1_release_notes.md b/guides/source/8_1_release_notes.md index 01da7baa6463b..1719aed05f5ec 100644 --- a/guides/source/8_1_release_notes.md +++ b/guides/source/8_1_release_notes.md @@ -28,6 +28,8 @@ Please refer to the [Changelog][railties] for detailed changes. ### Removals +* Remove deprecated `rails/console/methods.rb` file. + ### Deprecations ### Notable changes diff --git a/railties/CHANGELOG.md b/railties/CHANGELOG.md index 36a349b082e62..e1f2eebbed863 100644 --- a/railties/CHANGELOG.md +++ b/railties/CHANGELOG.md @@ -1,3 +1,7 @@ +* Remove deprecated `rails/console/methods.rb` file. + + *Rafael Mendonça França* + * Don't generate system tests by default. Rails scaffold generator will no longer generate system tests by default. To enable this pass `--system-tests=true` or generate them with `bin/rails generate system_test name_of_test`. diff --git a/railties/lib/rails/console/methods.rb b/railties/lib/rails/console/methods.rb deleted file mode 100644 index 0f4066f2322d8..0000000000000 --- a/railties/lib/rails/console/methods.rb +++ /dev/null @@ -1,7 +0,0 @@ -# frozen_string_literal: true - -Rails.deprecator.warn(<<~MSG, caller_locations(0..1)) -`rails/console/methods` has been deprecated and will be removed in Rails 8.1. -Please directly use IRB's extension API to add new commands or helpers to the console. -For more details, please visit: https://github.com/ruby/irb/blob/master/EXTEND_IRB.md -MSG From cf7d5e47a91ec68333cd18a02704471bedd047ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Thu, 9 Oct 2025 23:45:27 +0000 Subject: [PATCH 0753/1075] Remove deprecated `bin/rake stats` command --- guides/source/8_1_release_notes.md | 2 ++ railties/CHANGELOG.md | 4 ++++ .../rails/plugin/templates/Rakefile.tt | 4 ---- railties/lib/rails/tasks.rb | 4 +--- railties/lib/rails/tasks/statistics.rake | 19 ++++--------------- 5 files changed, 11 insertions(+), 22 deletions(-) diff --git a/guides/source/8_1_release_notes.md b/guides/source/8_1_release_notes.md index 1719aed05f5ec..bfc1937a955c8 100644 --- a/guides/source/8_1_release_notes.md +++ b/guides/source/8_1_release_notes.md @@ -30,6 +30,8 @@ Please refer to the [Changelog][railties] for detailed changes. * Remove deprecated `rails/console/methods.rb` file. +* Remove deprecated `bin/rake stats` command. + ### Deprecations ### Notable changes diff --git a/railties/CHANGELOG.md b/railties/CHANGELOG.md index e1f2eebbed863..8f5dc247ee139 100644 --- a/railties/CHANGELOG.md +++ b/railties/CHANGELOG.md @@ -1,3 +1,7 @@ +* Remove deprecated `bin/rake stats` command. + + *Rafael Mendonça França* + * Remove deprecated `rails/console/methods.rb` file. *Rafael Mendonça França* diff --git a/railties/lib/rails/generators/rails/plugin/templates/Rakefile.tt b/railties/lib/rails/generators/rails/plugin/templates/Rakefile.tt index d4b84b003509f..b00bfdb52744a 100644 --- a/railties/lib/rails/generators/rails/plugin/templates/Rakefile.tt +++ b/railties/lib/rails/generators/rails/plugin/templates/Rakefile.tt @@ -4,10 +4,6 @@ require "bundler/setup" APP_RAKEFILE = File.expand_path("<%= dummy_path -%>/Rakefile", __dir__) load "rails/tasks/engine.rake" <% end -%> -<% if engine? -%> - -load "rails/tasks/statistics.rake" -<% end -%> <% unless options[:skip_gemspec] -%> require "bundler/gem_tasks" diff --git a/railties/lib/rails/tasks.rb b/railties/lib/rails/tasks.rb index 511fbe8d8f83f..cc5666e1c0a28 100644 --- a/railties/lib/rails/tasks.rb +++ b/railties/lib/rails/tasks.rb @@ -10,8 +10,6 @@ tmp yarn zeitwerk -).tap { |arr| - arr << "statistics" if Rake.application.current_scope.empty? -}.each do |task| +).each do |task| load "rails/tasks/#{task}.rake" end diff --git a/railties/lib/rails/tasks/statistics.rake b/railties/lib/rails/tasks/statistics.rake index e62b53ad22608..9a9b919f0fedb 100644 --- a/railties/lib/rails/tasks/statistics.rake +++ b/railties/lib/rails/tasks/statistics.rake @@ -1,23 +1,12 @@ # frozen_string_literal: true +Rails.deprecator.warn <<~TEXT + rails/tasks/statistics.rake is deprecated and will be removed in Rails 8.2 without replacement. +TEXT + require "rails/code_statistics" STATS_DIRECTORIES = ActiveSupport::Deprecation::DeprecatedObjectProxy.new( Rails::CodeStatistics::DIRECTORIES, "`STATS_DIRECTORIES` is deprecated and will be removed in Rails 8.1! Use `Rails::CodeStatistics.register_directory('My Directory', 'path/to/dir)` instead.", Rails.deprecator ) - -desc "Report code statistics (KLOCs, etc) from the application or engine" -task :stats do - require "rails/code_statistics" - stat_directories = STATS_DIRECTORIES.collect do |name, dir| - [ name, "#{File.dirname(Rake.application.rakefile_location)}/#{dir}" ] - end.select { |name, dir| File.directory?(dir) } - - $stderr.puts Rails.deprecator.warn(<<~MSG, caller_locations(0..1)) - `bin/rake stats` has been deprecated and will be removed in Rails 8.1. - Please use `bin/rails stats` as Rails command instead.\n - MSG - - Rails::CodeStatistics.new(*stat_directories).to_s -end From d4b2d78c0c95e0a37d088cd378d27522073fd738 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Thu, 9 Oct 2025 23:47:52 +0000 Subject: [PATCH 0754/1075] Remove deprecated `STATS_DIRECTORIES` --- guides/source/8_1_release_notes.md | 2 ++ railties/CHANGELOG.md | 4 ++++ railties/lib/rails/tasks/statistics.rake | 7 ------- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/guides/source/8_1_release_notes.md b/guides/source/8_1_release_notes.md index bfc1937a955c8..6418ff519638e 100644 --- a/guides/source/8_1_release_notes.md +++ b/guides/source/8_1_release_notes.md @@ -32,6 +32,8 @@ Please refer to the [Changelog][railties] for detailed changes. * Remove deprecated `bin/rake stats` command. +* Remove deprecated `STATS_DIRECTORIES`. + ### Deprecations ### Notable changes diff --git a/railties/CHANGELOG.md b/railties/CHANGELOG.md index 8f5dc247ee139..aab6c99d37b4d 100644 --- a/railties/CHANGELOG.md +++ b/railties/CHANGELOG.md @@ -1,3 +1,7 @@ +* Remove deprecated `STATS_DIRECTORIES`. + + *Rafael Mendonça França* + * Remove deprecated `bin/rake stats` command. *Rafael Mendonça França* diff --git a/railties/lib/rails/tasks/statistics.rake b/railties/lib/rails/tasks/statistics.rake index 9a9b919f0fedb..228c64dea3f55 100644 --- a/railties/lib/rails/tasks/statistics.rake +++ b/railties/lib/rails/tasks/statistics.rake @@ -3,10 +3,3 @@ Rails.deprecator.warn <<~TEXT rails/tasks/statistics.rake is deprecated and will be removed in Rails 8.2 without replacement. TEXT - -require "rails/code_statistics" -STATS_DIRECTORIES = ActiveSupport::Deprecation::DeprecatedObjectProxy.new( - Rails::CodeStatistics::DIRECTORIES, - "`STATS_DIRECTORIES` is deprecated and will be removed in Rails 8.1! Use `Rails::CodeStatistics.register_directory('My Directory', 'path/to/dir)` instead.", - Rails.deprecator -) From f22c12ea20ebba6a81a9c7a89cae8a28fe14862d Mon Sep 17 00:00:00 2001 From: Yasuo Honda Date: Fri, 10 Oct 2025 13:29:34 +0900 Subject: [PATCH 0755/1075] Skip `bundler-audit` for Ruby 3.5.0.dev with Bundler 4.0.0.dev tentatively This commit workarounds the Rails Nightly CI failure with Ruby 3.5.0.dev that installs the Bundler version 4.0.0.dev, which is not compatible with bundler-audit yet. https://buildkite.com/rails/rails-nightly/builds/2964#0199cac6-9634-4a4f-b049-9340551022fe/157-209 - Steps to reproduce Install Ruby 3.5.0.dev and follow these steps below. ``` git clone https://github.com/rails/rails cd rails rm Gemfile.lock bundle install ``` - Actual result without this commit ```ruby $ ruby -v ruby 3.5.0dev (2025-10-09T21:11:53Z master 83d0b064c8) +PRISM [x86_64-linux] $ gem -v 4.0.0.dev $ bundler -v 4.0.0.dev $ bundle install Fetching gem metadata from https://rubygems.org/......... Resolving dependencies... Could not find compatible versions Because bundler-audit >= 0.1.2, < 0.6.1 depends on bundler ~> 1.2 and bundler-audit < 0.1.2 depends on bundler ~> 1.0, bundler-audit < 0.6.1 requires bundler >= 1.0, < 2.A. And because bundler-audit >= 0.6.1 depends on bundler >= 1.2.0, < 3, bundler >= 1.0, < 3 is required. Because the current Bundler version (4.0.0.dev) does not satisfy bundler >= 1.15.0, < 3 and every version of rails depends on bundler >= 1.15.0, every version of rails requires bundler >= 3. Thus, rails cannot be used. So, because Gemfile depends on rails >= 0, version solving has failed. Your bundle requires a different version of Bundler than the one you're running. Install the necessary version with `gem install bundler:2.7.2` and rerun bundler using `bundle _2.7.2_ install` $ ``` - Refer to https://github.com/ruby/ruby/commit/afe40df42331e338411c84714ca5d21383dac3d4 https://github.com/rubysec/bundler-audit/issues/405 --- Gemfile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Gemfile b/Gemfile index 9252ddd426b93..4296f3c2563ac 100644 --- a/Gemfile +++ b/Gemfile @@ -142,7 +142,8 @@ group :test do # Needed for Railties tests because it is included in generated apps. gem "brakeman" - gem "bundler-audit" + # Skip bundler-audit until https://github.com/rubysec/bundler-audit/issues/405 is resolved for Ruby 3.5.0dev + gem "bundler-audit" if Gem::Version.new(RUBY_VERSION) < Gem::Version.new("3.5.0") end platforms :ruby, :windows do From d796716f8d31af9c63a364979a4492eb72c3291a Mon Sep 17 00:00:00 2001 From: Yasuo Honda Date: Fri, 10 Oct 2025 14:14:41 +0900 Subject: [PATCH 0756/1075] Fix intermittent failure in test_timeout_in_fixture_set_insertion_doesnt_query_closed_connection Increase fixtures from 1000 to 2000 to make the timeout deterministic. On my AMD Ryzen 9 7940HS this test fails about once in ten runs with 1000 because the insert sometimes finishes within 0.1 seconds and no Timeout::Error is raised. ```ruby $ ARCONN=trilogy bin/test test/cases/adapters/trilogy/trilogy_adapter_test.rb -n test_timeout_in_fixture_set_insertion_doesnt_query_closed_connection Using trilogy Run options: -n test_timeout_in_fixture_set_insertion_doesnt_query_closed_connection --seed 13404 F Failure: TrilogyAdapterTest#test_timeout_in_fixture_set_insertion_doesnt_query_closed_connection [test/cases/adapters/trilogy/trilogy_adapter_test.rb:38]: Timeout::Error expected but nothing was raised. bin/test test/cases/adapters/trilogy/trilogy_adapter_test.rb:31 Finished in 0.097361s, 10.2710 runs/s, 10.2710 assertions/s. 1 runs, 1 assertions, 1 failures, 0 errors, 0 skips $ ``` --- .../test/cases/adapters/trilogy/trilogy_adapter_test.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/activerecord/test/cases/adapters/trilogy/trilogy_adapter_test.rb b/activerecord/test/cases/adapters/trilogy/trilogy_adapter_test.rb index a55f92c5fe6dd..44a561354cf31 100644 --- a/activerecord/test/cases/adapters/trilogy/trilogy_adapter_test.rb +++ b/activerecord/test/cases/adapters/trilogy/trilogy_adapter_test.rb @@ -33,7 +33,7 @@ class TrilogyAdapterTest < ActiveRecord::TrilogyTestCase ["traffic_lights", [ { "location" => "US", "state" => ["NY"], "long_state" => ["a"] }, ]] - ] * 1000 + ] * 2000 assert_raises(Timeout::Error) do Timeout.timeout(0.1) do From 009b900b96e2e178a0e0ca9859a5e8a7b64bcc3e Mon Sep 17 00:00:00 2001 From: Sean Doyle Date: Wed, 3 Jan 2024 11:11:15 -0500 Subject: [PATCH 0757/1075] Execute `action_text:install` task from Error page Mirror Active Storage's encouragement to execute the `active_storage:install` task from Action Dispatch error pages by adding similar instructions for the `rails action_text:install` task when the error is related to missing Action Text tables. --- .../rescues/invalid_statement.html.erb | 3 ++ .../rescues/invalid_statement.text.erb | 3 ++ railties/CHANGELOG.md | 4 ++ .../installation_integration_test.rb | 40 +++++++++++++++++++ 4 files changed, 50 insertions(+) create mode 100644 railties/test/application/action_text/installation_integration_test.rb diff --git a/actionpack/lib/action_dispatch/middleware/templates/rescues/invalid_statement.html.erb b/actionpack/lib/action_dispatch/middleware/templates/rescues/invalid_statement.html.erb index d3f4f02a781bc..030838860d925 100644 --- a/actionpack/lib/action_dispatch/middleware/templates/rescues/invalid_statement.html.erb +++ b/actionpack/lib/action_dispatch/middleware/templates/rescues/invalid_statement.html.erb @@ -11,6 +11,9 @@

<%= h @exception.message %> + <% if defined?(ActionText) && @exception.message.match?(%r{#{ActionText::RichText.table_name}}) %> +
To resolve this issue run: bin/rails action_text:install + <% end %> <% if defined?(ActiveStorage) && @exception.message.match?(%r{#{ActiveStorage::Blob.table_name}|#{ActiveStorage::Attachment.table_name}}) %>
To resolve this issue run: bin/rails active_storage:install <% end %> diff --git a/actionpack/lib/action_dispatch/middleware/templates/rescues/invalid_statement.text.erb b/actionpack/lib/action_dispatch/middleware/templates/rescues/invalid_statement.text.erb index d30facd3123c5..dc3a05a1a523b 100644 --- a/actionpack/lib/action_dispatch/middleware/templates/rescues/invalid_statement.text.erb +++ b/actionpack/lib/action_dispatch/middleware/templates/rescues/invalid_statement.text.erb @@ -4,6 +4,9 @@ <% end %> <%= @exception.message %> +<% if defined?(ActionText) && @exception.message.match?(%r{#{ActionText::RichText.table_name}}) %> +To resolve this issue run: bin/rails action_text:install +<% end %> <% if defined?(ActiveStorage) && @exception.message.match?(%r{#{ActiveStorage::Blob.table_name}|#{ActiveStorage::Attachment.table_name}}) %> To resolve this issue run: bin/rails active_storage:install <% end %> diff --git a/railties/CHANGELOG.md b/railties/CHANGELOG.md index aab6c99d37b4d..f743bbff307ef 100644 --- a/railties/CHANGELOG.md +++ b/railties/CHANGELOG.md @@ -1,3 +1,7 @@ +* Suggest `bin/rails action_text:install` from Action Dispatch error page + + *Sean Doyle* + * Remove deprecated `STATS_DIRECTORIES`. *Rafael Mendonça França* diff --git a/railties/test/application/action_text/installation_integration_test.rb b/railties/test/application/action_text/installation_integration_test.rb new file mode 100644 index 0000000000000..4888e9ba8c762 --- /dev/null +++ b/railties/test/application/action_text/installation_integration_test.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require "isolation/abstract_unit" +require "rack/test" + +module ApplicationTests + class InstallationIntegrationTest < ActiveSupport::TestCase + include ActiveSupport::Testing::Isolation + include Rack::Test::Methods + + def setup + build_app + end + + def teardown + teardown_app + end + + def test_renders_actionable_exception_page_when_action_text_not_installed + app "development" + app_file "config/routes.rb", <<~RUBY + Rails.application.routes.draw do + post "/rich_texts" => "rich_texts#create" + end + RUBY + app_file "app/controllers/rich_texts_controller.rb", <<~RUBY + class RichTextsController < ActionController::Base + def create + ActionText::RichText.create! + end + end + RUBY + + post "/rich_texts" + + assert_equal 500, last_response.status + assert_match "To resolve this issue run: bin/rails action_text:install", last_response.body + end + end +end From 48ca2f8b91f69ae7c08cc16426481a5e17fd2209 Mon Sep 17 00:00:00 2001 From: Xavier Noria Date: Sat, 11 Oct 2025 09:12:29 +0200 Subject: [PATCH 0758/1075] Load core extensions with require_relative --- .../lib/active_support/core_ext/array.rb | 14 +++++----- .../active_support/core_ext/big_decimal.rb | 2 +- .../lib/active_support/core_ext/class.rb | 4 +-- .../lib/active_support/core_ext/date.rb | 10 +++---- .../lib/active_support/core_ext/date_time.rb | 10 +++---- .../lib/active_support/core_ext/digest.rb | 2 +- .../lib/active_support/core_ext/file.rb | 2 +- .../lib/active_support/core_ext/hash.rb | 16 ++++++------ .../lib/active_support/core_ext/integer.rb | 6 ++--- .../lib/active_support/core_ext/kernel.rb | 6 ++--- .../lib/active_support/core_ext/module.rb | 22 ++++++++-------- .../lib/active_support/core_ext/numeric.rb | 6 ++--- .../lib/active_support/core_ext/object.rb | 26 +++++++++---------- .../lib/active_support/core_ext/pathname.rb | 4 +-- .../lib/active_support/core_ext/range.rb | 8 +++--- .../lib/active_support/core_ext/string.rb | 26 +++++++++---------- .../lib/active_support/core_ext/symbol.rb | 2 +- .../lib/active_support/core_ext/time.rb | 10 +++---- 18 files changed, 88 insertions(+), 88 deletions(-) diff --git a/activesupport/lib/active_support/core_ext/array.rb b/activesupport/lib/active_support/core_ext/array.rb index 88b6567712379..913c798c75b20 100644 --- a/activesupport/lib/active_support/core_ext/array.rb +++ b/activesupport/lib/active_support/core_ext/array.rb @@ -1,9 +1,9 @@ # frozen_string_literal: true -require "active_support/core_ext/array/wrap" -require "active_support/core_ext/array/access" -require "active_support/core_ext/array/conversions" -require "active_support/core_ext/array/extract" -require "active_support/core_ext/array/extract_options" -require "active_support/core_ext/array/grouping" -require "active_support/core_ext/array/inquiry" +require_relative "array/wrap" +require_relative "array/access" +require_relative "array/conversions" +require_relative "array/extract" +require_relative "array/extract_options" +require_relative "array/grouping" +require_relative "array/inquiry" diff --git a/activesupport/lib/active_support/core_ext/big_decimal.rb b/activesupport/lib/active_support/core_ext/big_decimal.rb index 9e6a9d6331692..5568db689b42f 100644 --- a/activesupport/lib/active_support/core_ext/big_decimal.rb +++ b/activesupport/lib/active_support/core_ext/big_decimal.rb @@ -1,3 +1,3 @@ # frozen_string_literal: true -require "active_support/core_ext/big_decimal/conversions" +require_relative "big_decimal/conversions" diff --git a/activesupport/lib/active_support/core_ext/class.rb b/activesupport/lib/active_support/core_ext/class.rb index 1c110fd07bd5c..65e1d683999ac 100644 --- a/activesupport/lib/active_support/core_ext/class.rb +++ b/activesupport/lib/active_support/core_ext/class.rb @@ -1,4 +1,4 @@ # frozen_string_literal: true -require "active_support/core_ext/class/attribute" -require "active_support/core_ext/class/subclasses" +require_relative "class/attribute" +require_relative "class/subclasses" diff --git a/activesupport/lib/active_support/core_ext/date.rb b/activesupport/lib/active_support/core_ext/date.rb index cce73f2db2232..2a2ed5496f4b2 100644 --- a/activesupport/lib/active_support/core_ext/date.rb +++ b/activesupport/lib/active_support/core_ext/date.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true -require "active_support/core_ext/date/acts_like" -require "active_support/core_ext/date/blank" -require "active_support/core_ext/date/calculations" -require "active_support/core_ext/date/conversions" -require "active_support/core_ext/date/zones" +require_relative "date/acts_like" +require_relative "date/blank" +require_relative "date/calculations" +require_relative "date/conversions" +require_relative "date/zones" diff --git a/activesupport/lib/active_support/core_ext/date_time.rb b/activesupport/lib/active_support/core_ext/date_time.rb index 790dbeec1b479..b3d6773e4fc2b 100644 --- a/activesupport/lib/active_support/core_ext/date_time.rb +++ b/activesupport/lib/active_support/core_ext/date_time.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true -require "active_support/core_ext/date_time/acts_like" -require "active_support/core_ext/date_time/blank" -require "active_support/core_ext/date_time/calculations" -require "active_support/core_ext/date_time/compatibility" -require "active_support/core_ext/date_time/conversions" +require_relative "date_time/acts_like" +require_relative "date_time/blank" +require_relative "date_time/calculations" +require_relative "date_time/compatibility" +require_relative "date_time/conversions" diff --git a/activesupport/lib/active_support/core_ext/digest.rb b/activesupport/lib/active_support/core_ext/digest.rb index ce1427e13a0d6..3f1d38e418fb2 100644 --- a/activesupport/lib/active_support/core_ext/digest.rb +++ b/activesupport/lib/active_support/core_ext/digest.rb @@ -1,3 +1,3 @@ # frozen_string_literal: true -require "active_support/core_ext/digest/uuid" +require_relative "digest/uuid" diff --git a/activesupport/lib/active_support/core_ext/file.rb b/activesupport/lib/active_support/core_ext/file.rb index 64553bfa4e82e..3c2364167dd6e 100644 --- a/activesupport/lib/active_support/core_ext/file.rb +++ b/activesupport/lib/active_support/core_ext/file.rb @@ -1,3 +1,3 @@ # frozen_string_literal: true -require "active_support/core_ext/file/atomic" +require_relative "file/atomic" diff --git a/activesupport/lib/active_support/core_ext/hash.rb b/activesupport/lib/active_support/core_ext/hash.rb index 2f0901d8534b4..363cd8bf10ca6 100644 --- a/activesupport/lib/active_support/core_ext/hash.rb +++ b/activesupport/lib/active_support/core_ext/hash.rb @@ -1,10 +1,10 @@ # frozen_string_literal: true -require "active_support/core_ext/hash/conversions" -require "active_support/core_ext/hash/deep_merge" -require "active_support/core_ext/hash/deep_transform_values" -require "active_support/core_ext/hash/except" -require "active_support/core_ext/hash/indifferent_access" -require "active_support/core_ext/hash/keys" -require "active_support/core_ext/hash/reverse_merge" -require "active_support/core_ext/hash/slice" +require_relative "hash/conversions" +require_relative "hash/deep_merge" +require_relative "hash/deep_transform_values" +require_relative "hash/except" +require_relative "hash/indifferent_access" +require_relative "hash/keys" +require_relative "hash/reverse_merge" +require_relative "hash/slice" diff --git a/activesupport/lib/active_support/core_ext/integer.rb b/activesupport/lib/active_support/core_ext/integer.rb index d22701306a1ca..df80e7ffdba70 100644 --- a/activesupport/lib/active_support/core_ext/integer.rb +++ b/activesupport/lib/active_support/core_ext/integer.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true -require "active_support/core_ext/integer/multiple" -require "active_support/core_ext/integer/inflections" -require "active_support/core_ext/integer/time" +require_relative "integer/multiple" +require_relative "integer/inflections" +require_relative "integer/time" diff --git a/activesupport/lib/active_support/core_ext/kernel.rb b/activesupport/lib/active_support/core_ext/kernel.rb index 7708069301d71..55a44fdef9dc4 100644 --- a/activesupport/lib/active_support/core_ext/kernel.rb +++ b/activesupport/lib/active_support/core_ext/kernel.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true -require "active_support/core_ext/kernel/concern" -require "active_support/core_ext/kernel/reporting" -require "active_support/core_ext/kernel/singleton_class" +require_relative "kernel/concern" +require_relative "kernel/reporting" +require_relative "kernel/singleton_class" diff --git a/activesupport/lib/active_support/core_ext/module.rb b/activesupport/lib/active_support/core_ext/module.rb index 542af98c04f7d..9a30fe2727730 100644 --- a/activesupport/lib/active_support/core_ext/module.rb +++ b/activesupport/lib/active_support/core_ext/module.rb @@ -1,13 +1,13 @@ # frozen_string_literal: true -require "active_support/core_ext/module/aliasing" -require "active_support/core_ext/module/introspection" -require "active_support/core_ext/module/anonymous" -require "active_support/core_ext/module/attribute_accessors" -require "active_support/core_ext/module/attribute_accessors_per_thread" -require "active_support/core_ext/module/attr_internal" -require "active_support/core_ext/module/concerning" -require "active_support/core_ext/module/delegation" -require "active_support/core_ext/module/deprecation" -require "active_support/core_ext/module/redefine_method" -require "active_support/core_ext/module/remove_method" +require_relative "module/aliasing" +require_relative "module/introspection" +require_relative "module/anonymous" +require_relative "module/attribute_accessors" +require_relative "module/attribute_accessors_per_thread" +require_relative "module/attr_internal" +require_relative "module/concerning" +require_relative "module/delegation" +require_relative "module/deprecation" +require_relative "module/redefine_method" +require_relative "module/remove_method" diff --git a/activesupport/lib/active_support/core_ext/numeric.rb b/activesupport/lib/active_support/core_ext/numeric.rb index fe778470f1649..949efcb726c1f 100644 --- a/activesupport/lib/active_support/core_ext/numeric.rb +++ b/activesupport/lib/active_support/core_ext/numeric.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true -require "active_support/core_ext/numeric/bytes" -require "active_support/core_ext/numeric/time" -require "active_support/core_ext/numeric/conversions" +require_relative "numeric/bytes" +require_relative "numeric/time" +require_relative "numeric/conversions" diff --git a/activesupport/lib/active_support/core_ext/object.rb b/activesupport/lib/active_support/core_ext/object.rb index 7a7f0d99817ec..097e4712f05ca 100644 --- a/activesupport/lib/active_support/core_ext/object.rb +++ b/activesupport/lib/active_support/core_ext/object.rb @@ -1,17 +1,17 @@ # frozen_string_literal: true -require "active_support/core_ext/object/acts_like" -require "active_support/core_ext/object/blank" -require "active_support/core_ext/object/duplicable" -require "active_support/core_ext/object/deep_dup" -require "active_support/core_ext/object/try" -require "active_support/core_ext/object/inclusion" +require_relative "object/acts_like" +require_relative "object/blank" +require_relative "object/duplicable" +require_relative "object/deep_dup" +require_relative "object/try" +require_relative "object/inclusion" -require "active_support/core_ext/object/conversions" -require "active_support/core_ext/object/instance_variables" +require_relative "object/conversions" +require_relative "object/instance_variables" -require "active_support/core_ext/object/json" -require "active_support/core_ext/object/to_param" -require "active_support/core_ext/object/to_query" -require "active_support/core_ext/object/with" -require "active_support/core_ext/object/with_options" +require_relative "object/json" +require_relative "object/to_param" +require_relative "object/to_query" +require_relative "object/with" +require_relative "object/with_options" diff --git a/activesupport/lib/active_support/core_ext/pathname.rb b/activesupport/lib/active_support/core_ext/pathname.rb index 10fa1903bead7..8d1f42b5acb9c 100644 --- a/activesupport/lib/active_support/core_ext/pathname.rb +++ b/activesupport/lib/active_support/core_ext/pathname.rb @@ -1,4 +1,4 @@ # frozen_string_literal: true -require "active_support/core_ext/pathname/blank" -require "active_support/core_ext/pathname/existence" +require_relative "pathname/blank" +require_relative "pathname/existence" diff --git a/activesupport/lib/active_support/core_ext/range.rb b/activesupport/lib/active_support/core_ext/range.rb index a2e678c2cb001..d9fc27183ca4e 100644 --- a/activesupport/lib/active_support/core_ext/range.rb +++ b/activesupport/lib/active_support/core_ext/range.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require "active_support/core_ext/range/conversions" -require "active_support/core_ext/range/compare_range" -require "active_support/core_ext/range/overlap" -require "active_support/core_ext/range/sole" +require_relative "range/conversions" +require_relative "range/compare_range" +require_relative "range/overlap" +require_relative "range/sole" diff --git a/activesupport/lib/active_support/core_ext/string.rb b/activesupport/lib/active_support/core_ext/string.rb index 757d15c51ae49..491eec2fc9955 100644 --- a/activesupport/lib/active_support/core_ext/string.rb +++ b/activesupport/lib/active_support/core_ext/string.rb @@ -1,15 +1,15 @@ # frozen_string_literal: true -require "active_support/core_ext/string/conversions" -require "active_support/core_ext/string/filters" -require "active_support/core_ext/string/multibyte" -require "active_support/core_ext/string/starts_ends_with" -require "active_support/core_ext/string/inflections" -require "active_support/core_ext/string/access" -require "active_support/core_ext/string/behavior" -require "active_support/core_ext/string/output_safety" -require "active_support/core_ext/string/exclude" -require "active_support/core_ext/string/strip" -require "active_support/core_ext/string/inquiry" -require "active_support/core_ext/string/indent" -require "active_support/core_ext/string/zones" +require_relative "string/conversions" +require_relative "string/filters" +require_relative "string/multibyte" +require_relative "string/starts_ends_with" +require_relative "string/inflections" +require_relative "string/access" +require_relative "string/behavior" +require_relative "string/output_safety" +require_relative "string/exclude" +require_relative "string/strip" +require_relative "string/inquiry" +require_relative "string/indent" +require_relative "string/zones" diff --git a/activesupport/lib/active_support/core_ext/symbol.rb b/activesupport/lib/active_support/core_ext/symbol.rb index 709fed2024f2b..f5379ff3a394e 100644 --- a/activesupport/lib/active_support/core_ext/symbol.rb +++ b/activesupport/lib/active_support/core_ext/symbol.rb @@ -1,3 +1,3 @@ # frozen_string_literal: true -require "active_support/core_ext/symbol/starts_ends_with" +require_relative "symbol/starts_ends_with" diff --git a/activesupport/lib/active_support/core_ext/time.rb b/activesupport/lib/active_support/core_ext/time.rb index c809def05f35b..4e16274443d71 100644 --- a/activesupport/lib/active_support/core_ext/time.rb +++ b/activesupport/lib/active_support/core_ext/time.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true -require "active_support/core_ext/time/acts_like" -require "active_support/core_ext/time/calculations" -require "active_support/core_ext/time/compatibility" -require "active_support/core_ext/time/conversions" -require "active_support/core_ext/time/zones" +require_relative "time/acts_like" +require_relative "time/calculations" +require_relative "time/compatibility" +require_relative "time/conversions" +require_relative "time/zones" From afe692475770eca9c890b583e23ffe453351349c Mon Sep 17 00:00:00 2001 From: Gannon McGibbon Date: Sun, 12 Oct 2025 11:36:53 -0300 Subject: [PATCH 0759/1075] Fix verbose redirect output and route source mapping Previously, we would sometimes log nil when verbose logs are disabled, and dispatch route level source locations could not be found. --- .../lib/action_controller/log_subscriber.rb | 6 ++---- .../lib/action_dispatch/log_subscriber.rb | 11 ++--------- actionpack/lib/action_dispatch/railtie.rb | 4 ---- .../lib/action_dispatch/routing/redirection.rb | 17 ++++++++++------- .../test/controller/log_subscriber_test.rb | 3 ++- .../dispatch/routing/log_subscriber_test.rb | 3 ++- guides/source/8_1_release_notes.md | 2 ++ guides/source/active_support_instrumentation.md | 9 +++++---- 8 files changed, 25 insertions(+), 30 deletions(-) diff --git a/actionpack/lib/action_controller/log_subscriber.rb b/actionpack/lib/action_controller/log_subscriber.rb index 2838ad1c0278c..c9539553763d2 100644 --- a/actionpack/lib/action_controller/log_subscriber.rb +++ b/actionpack/lib/action_controller/log_subscriber.rb @@ -64,10 +64,8 @@ def send_file(event) def redirect_to(event) info { "Redirected to #{event.payload[:location]}" } - info do - if ActionDispatch.verbose_redirect_logs && (source = redirect_source_location) - "↳ #{source}" - end + if ActionDispatch.verbose_redirect_logs && (source = redirect_source_location) + info { "↳ #{source}" } end end subscribe_log_level :redirect_to, :info diff --git a/actionpack/lib/action_dispatch/log_subscriber.rb b/actionpack/lib/action_dispatch/log_subscriber.rb index 4298bfc065550..88c5ac6a00443 100644 --- a/actionpack/lib/action_dispatch/log_subscriber.rb +++ b/actionpack/lib/action_dispatch/log_subscriber.rb @@ -9,10 +9,8 @@ def redirect(event) info { "Redirected to #{payload[:location]}" } - info do - if ActionDispatch.verbose_redirect_logs && (source = redirect_source_location) - "↳ #{source}" - end + if ActionDispatch.verbose_redirect_logs + info { "↳ #{payload[:source_location]}" } end info do @@ -25,11 +23,6 @@ def redirect(event) end end subscribe_log_level :redirect, :info - - private - def redirect_source_location - backtrace_cleaner.first_clean_frame - end end end diff --git a/actionpack/lib/action_dispatch/railtie.rb b/actionpack/lib/action_dispatch/railtie.rb index 57180a9c41d25..492f384c019ad 100644 --- a/actionpack/lib/action_dispatch/railtie.rb +++ b/actionpack/lib/action_dispatch/railtie.rb @@ -91,9 +91,5 @@ class Railtie < Rails::Railtie # :nodoc: ActionDispatch::Http::Cache::Request.strict_freshness = app.config.action_dispatch.strict_freshness ActionDispatch.test_app = app end - - initializer "action_dispatch.backtrace_cleaner" do - ActionDispatch::LogSubscriber.backtrace_cleaner = Rails.backtrace_cleaner - end end end diff --git a/actionpack/lib/action_dispatch/routing/redirection.rb b/actionpack/lib/action_dispatch/routing/redirection.rb index 83c9e58bcbe2f..78fd4209ee2ff 100644 --- a/actionpack/lib/action_dispatch/routing/redirection.rb +++ b/actionpack/lib/action_dispatch/routing/redirection.rb @@ -12,9 +12,10 @@ module Routing class Redirect < Endpoint # :nodoc: attr_reader :status, :block - def initialize(status, block) + def initialize(status, block, source_location) @status = status @block = block + @source_location = source_location end def redirect?; true; end @@ -27,6 +28,7 @@ def call(env) payload[:status] = @status payload[:location] = response.headers["Location"] payload[:request] = request + payload[:source_location] = @source_location if @source_location response.to_a end @@ -202,16 +204,17 @@ module Redirection # get 'accounts/:name' => redirect(SubdomainRedirector.new('api')) # def redirect(*args, &block) - options = args.extract_options! - status = options.delete(:status) || 301 - path = args.shift + options = args.extract_options! + status = options.delete(:status) || 301 + path = args.shift + source_location = caller[0] if ActionDispatch.verbose_redirect_logs - return OptionRedirect.new(status, options) if options.any? - return PathRedirect.new(status, path) if String === path + return OptionRedirect.new(status, options, source_location) if options.any? + return PathRedirect.new(status, path, source_location) if String === path block = path if path.respond_to? :call raise ArgumentError, "redirection argument not supported" unless block - Redirect.new status, block + Redirect.new status, block, source_location end end end diff --git a/actionpack/test/controller/log_subscriber_test.rb b/actionpack/test/controller/log_subscriber_test.rb index c208d5e5dc0b3..0b6e5b044374e 100644 --- a/actionpack/test/controller/log_subscriber_test.rb +++ b/actionpack/test/controller/log_subscriber_test.rb @@ -318,6 +318,7 @@ def test_filter_redirect_bad_uri end def test_verbose_redirect_logs + line = Another::LogSubscribersController.instance_method(:redirector).source_location.last + 1 old_cleaner = ActionController::LogSubscriber.backtrace_cleaner ActionController::LogSubscriber.backtrace_cleaner = ActionController::LogSubscriber.backtrace_cleaner.dup ActionController::LogSubscriber.backtrace_cleaner.add_silencer { |location| !location.include?(__FILE__) } @@ -327,7 +328,7 @@ def test_verbose_redirect_logs wait assert_equal 4, logs.size - assert_match(/↳ #{__FILE__}/, logs[2]) + assert_match(/↳ #{__FILE__}:#{line}/, logs[2]) ensure ActionDispatch.verbose_redirect_logs = false ActionController::LogSubscriber.backtrace_cleaner = old_cleaner diff --git a/actionpack/test/dispatch/routing/log_subscriber_test.rb b/actionpack/test/dispatch/routing/log_subscriber_test.rb index 2c8e0253f6eed..d26359b5f3556 100644 --- a/actionpack/test/dispatch/routing/log_subscriber_test.rb +++ b/actionpack/test/dispatch/routing/log_subscriber_test.rb @@ -26,6 +26,7 @@ def setup end test "verbose redirect logs" do + line = __LINE__ + 7 old_cleaner = ActionDispatch::LogSubscriber.backtrace_cleaner ActionDispatch::LogSubscriber.backtrace_cleaner = ActionDispatch::LogSubscriber.backtrace_cleaner.dup ActionDispatch::LogSubscriber.backtrace_cleaner.add_silencer { |location| !location.include?(__FILE__) } @@ -39,7 +40,7 @@ def setup wait assert_equal 3, logs.size - assert_match(/↳ #{__FILE__}/, logs[1]) + assert_match(/↳ #{__FILE__}:#{line}/, logs[1]) ensure ActionDispatch.verbose_redirect_logs = false ActionDispatch::LogSubscriber.backtrace_cleaner = old_cleaner diff --git a/guides/source/8_1_release_notes.md b/guides/source/8_1_release_notes.md index 6418ff519638e..fe162b1685f08 100644 --- a/guides/source/8_1_release_notes.md +++ b/guides/source/8_1_release_notes.md @@ -96,6 +96,8 @@ Please refer to the [Changelog][action-pack] for detailed changes. ### Notable changes +* Redirects are now verbose in development for new Rails apps. To enable it in an existing app, add `config.action_dispatch.verbose_redirect_logs = true` to your `config/development.rb` file. + Action View ----------- diff --git a/guides/source/active_support_instrumentation.md b/guides/source/active_support_instrumentation.md index fb5d8a65d5a8f..f09c946964da8 100644 --- a/guides/source/active_support_instrumentation.md +++ b/guides/source/active_support_instrumentation.md @@ -287,10 +287,11 @@ Additional keys may be added by the caller. #### `redirect.action_dispatch` | Key | Value | -| ----------- | ---------------------------------------- | -| `:status` | HTTP response code | -| `:location` | URL to redirect to | -| `:request` | The [`ActionDispatch::Request`][] object | +| ------------------ | ---------------------------------------- | +| `:status` | HTTP response code | +| `:location` | URL to redirect to | +| `:request` | The [`ActionDispatch::Request`][] object | +| `:source_location` | Source location of redirect in routes | #### `request.action_dispatch` From fd5b0c81ae353f7b19ef8928a09595477729e856 Mon Sep 17 00:00:00 2001 From: Harsh Date: Sun, 12 Oct 2025 20:52:19 -0400 Subject: [PATCH 0760/1075] Change section header for email section to Action Mailer and Email Notifications instead of Adding In Stock Notifications The other sections in the tutorial are based on what rails feature they're teaching, instead of the application specific feature. Since this will be reflected in the table of contents, it'll help people jumping to the relevant section and people skimming when they have a little rails familiarity. --- guides/source/getting_started.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/guides/source/getting_started.md b/guides/source/getting_started.md index 0624ad39a47fa..754d27dccae5e 100644 --- a/guides/source/getting_started.md +++ b/guides/source/getting_started.md @@ -1959,7 +1959,7 @@ viewing the Spanish locale. Learn more about the [Rails Internationalization (I18n) API](i18n.html). -Adding In Stock Notifications +Action Mailer and Email Notifications ----------------------------- A common feature of e-commerce stores is an email subscription to get notified From cde0ac8da293f36b9e34dcbf84217e039547ac96 Mon Sep 17 00:00:00 2001 From: Yasuo Honda Date: Sun, 12 Oct 2025 10:47:43 +0900 Subject: [PATCH 0761/1075] Skip ApiAppGeneratorTest and AppGeneratorTest for Ruby 3.5.0dev tentatively This commit skips ApiAppGeneratorTest and AppGeneratorTest for Ruby 3.5.0dev until budndler-audit support it. -- Steps to reproduce 1. Update `railties/test/generators/generators_test_helper.rb` not to silence errors ``` $ bundle update --bundler $ git diff diff --git a/Gemfile.lock b/Gemfile.lock index 7a4c7e53ed..0b4bf3e94d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -151,9 +151,6 @@ GEM brakeman (7.0.0) racc builder (3.3.0) - bundler-audit (0.9.2) - bundler (>= 1.2.0, < 3) - thor (~> 1.0) bunny (2.23.0) amq-protocol (~> 2.3, >= 2.3.1) sorted_set (~> 1, >= 1.0.2) @@ -755,7 +752,6 @@ DEPENDENCIES bcrypt (~> 3.1.11) bootsnap (>= 1.4.4) brakeman - bundler-audit capybara (>= 3.39) connection_pool cssbundling-rails @@ -832,4 +828,4 @@ DEPENDENCIES websocket-client-simple BUNDLED WITH - 2.7.0 + 4.0.0.dev diff --git a/railties/test/generators/generators_test_helper.rb b/railties/test/generators/generators_test_helper.rb index 6bc08c3ca0..d0522bf878 100644 --- a/railties/test/generators/generators_test_helper.rb +++ b/railties/test/generators/generators_test_helper.rb @@ -147,7 +147,7 @@ def run_app_update(app_root = destination_root, flags: "--force") gemfile_contents.sub!(/^(gem "rails").*/, "\\1, path: #{File.expand_path("../../..", __dir__).inspect}") File.write("Gemfile", gemfile_contents) - silence_stream($stdout) { system({ "BUNDLE_GEMFILE" => "Gemfile" }, "bin/rails app:update #{flags}", exception: true) } + system({ "BUNDLE_GEMFILE" => "Gemfile" }, "bin/rails app:update #{flags}", exception: true) end end ``` - Actual result without skipping it ```ruby $ bin/test test/generators/api_app_generator_test.rb -n test_app_update_does_not_generate_public_files Run options: -n test_app_update_does_not_generate_public_files --seed 33882 Could not find gem 'bundler-audit' in locally installed gems. Run `bundle install` to install missing gems. E Error: ApiAppGeneratorTest#test_app_update_does_not_generate_public_files: RuntimeError: Command failed with exit 7: bin/rails test/generators/generators_test_helper.rb:150:in 'Kernel#system' test/generators/generators_test_helper.rb:150:in 'block in GeneratorsTestHelper#run_app_update' test/generators/generators_test_helper.rb:145:in 'Dir.chdir' test/generators/generators_test_helper.rb:145:in 'GeneratorsTestHelper#run_app_update' test/generators/api_app_generator_test.rb:138:in 'ApiAppGeneratorTest#test_app_update_does_not_generate_public_files' bin/test test/generators/api_app_generator_test.rb:136 Finished in 0.136068s, 7.3493 runs/s, 0.0000 assertions/s. 1 runs, 0 assertions, 0 failures, 1 errors, 0 skips $ ``` Since more than 20 failures or errors appeared in AppGeneratorTest this commit skips entire AppGeneratorTest for Ruby 3.5.0dev tentatively. - Number of failures or errors to be skipped ``` $ bin/test test/generators/app_generator_test.rb 192 runs, 1575 assertions, 2 failures, 23 errors, 0 skips $ bin/test test/generators/api_app_generator_test.rb 10 runs, 151 assertions, 0 failures, 1 errors, 0 skips ``` Follow up https://github.com/rails/rails/pull/55883 Related to https://github.com/rubysec/bundler-audit/issues/405 --- railties/test/generators/api_app_generator_test.rb | 2 ++ railties/test/generators/app_generator_test.rb | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/railties/test/generators/api_app_generator_test.rb b/railties/test/generators/api_app_generator_test.rb index 7686c81fa7464..aa3a70d11df12 100644 --- a/railties/test/generators/api_app_generator_test.rb +++ b/railties/test/generators/api_app_generator_test.rb @@ -134,6 +134,8 @@ def test_app_update_does_not_generate_unnecessary_bin_files end def test_app_update_does_not_generate_public_files + skip "bundler-audit does not support Ruby 3.5.0dev yet" unless Gem.loaded_specs.key?("bundler-audit") + run_generator run_app_update diff --git a/railties/test/generators/app_generator_test.rb b/railties/test/generators/app_generator_test.rb index f5390542b4c15..e148d87ea4625 100644 --- a/railties/test/generators/app_generator_test.rb +++ b/railties/test/generators/app_generator_test.rb @@ -97,6 +97,10 @@ class AppGeneratorTest < Rails::Generators::TestCase # brings setup, teardown, and some tests include SharedGeneratorTests + setup do + skip "bundler-audit does not support Ruby 3.5.0dev yet" unless Gem.loaded_specs.key?("bundler-audit") + end + def default_files ::DEFAULT_APP_FILES end From 9a8925ef743f91239cd8cad84a79cf035ed12372 Mon Sep 17 00:00:00 2001 From: hachi8833 Date: Mon, 13 Oct 2025 12:54:50 +0900 Subject: [PATCH 0762/1075] [ci-skip][doc] Fix misspell in action_controller_overview.md --- guides/source/action_controller_overview.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/guides/source/action_controller_overview.md b/guides/source/action_controller_overview.md index 5a6e0ea72a821..fc536892145af 100644 --- a/guides/source/action_controller_overview.md +++ b/guides/source/action_controller_overview.md @@ -1241,7 +1241,7 @@ information in a request. For example, controllers responding to requests from a mobile platform might need to render different content than requests from a desktop browser. One strategy to accomplish this is by customizing a request's variant. Variant names are arbitrary, and can communicate anything from the -request's platform (`:anrdoid`, `:ios`, `:linux`, `:macos`, `:windows`) to its +request's platform (`:android`, `:ios`, `:linux`, `:macos`, `:windows`) to its browser (`:chrome`, `:edge`, `:firefox`, `:safari`), to the type of user (`:admin`, `:guest`, `:user`). From 7ced1b59acd82c7960816ab7dbe325bd8f644eec Mon Sep 17 00:00:00 2001 From: hachi8833 Date: Mon, 13 Oct 2025 20:18:42 +0900 Subject: [PATCH 0763/1075] [ci-skip][doc] Update warning for caching_with_rails.md --- guides/source/caching_with_rails.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/guides/source/caching_with_rails.md b/guides/source/caching_with_rails.md index 0f5c5bbb383d5..67932ff86a994 100644 --- a/guides/source/caching_with_rails.md +++ b/guides/source/caching_with_rails.md @@ -396,13 +396,13 @@ you'd prefer not to utilize it, you can skip Solid Cache: rails new app_name --skip-solid ``` -WARNING: All parts of the Solid Trifecta (Solid Cache, Solid Queue and Solid -Cable) are bundled behind the `--skip-solid` flag. If you still want to use -Solid Queue and Solid Cable but not Solid Cache, you can install them -separately by following [Solid Queue -Installation](https://github.com/rails/solid_queue#installation) and -[Solid Cable Installation](https://github.com/rails/solid_cable#installation) -respectively. +NOTE: Using the --skip-solid flag skips all parts of the Solid +Trifecta (Solid Cache, Solid Queue, and Solid Cable).If you still +want to use some of them, you can install them separately. For +example, if you want to use Solid Queue and Solid Cable but not +Solid Cache, you can follow the installation guides for [Solid +Queue](https://github.com/rails/solid_queue#installation) and +[Solid Cable](https://github.com/rails/solid_cable#installation). ### Configuring the Database From f214f332c321ebc72d44417f8d6e1e9b8a5e63d5 Mon Sep 17 00:00:00 2001 From: hachi8833 Date: Mon, 13 Oct 2025 20:22:40 +0900 Subject: [PATCH 0764/1075] [ci-skip][doc] add backquotes to flag --- guides/source/caching_with_rails.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/guides/source/caching_with_rails.md b/guides/source/caching_with_rails.md index 67932ff86a994..92462ffe2feb3 100644 --- a/guides/source/caching_with_rails.md +++ b/guides/source/caching_with_rails.md @@ -396,7 +396,7 @@ you'd prefer not to utilize it, you can skip Solid Cache: rails new app_name --skip-solid ``` -NOTE: Using the --skip-solid flag skips all parts of the Solid +NOTE: Using the `--skip-solid` flag skips all parts of the Solid Trifecta (Solid Cache, Solid Queue, and Solid Cable).If you still want to use some of them, you can install them separately. For example, if you want to use Solid Queue and Solid Cable but not From cc8ac6cc656bab1ea495e63895bd51f849babb71 Mon Sep 17 00:00:00 2001 From: Hartley McGuire Date: Mon, 13 Oct 2025 22:16:11 +0000 Subject: [PATCH 0765/1075] Extract dump/load/{,gzip} tests for each format Previously there were some small inconsistencies between each of the format/gzip combinations, this aligns all of them. It additionally fixes a few signatures which were incorrect (connection vs pool), but still passed tests since the pool is never used. --- .../connection_adapters/schema_cache_test.rb | 277 +++++++++--------- 1 file changed, 140 insertions(+), 137 deletions(-) diff --git a/activerecord/test/cases/connection_adapters/schema_cache_test.rb b/activerecord/test/cases/connection_adapters/schema_cache_test.rb index ebd42efb3e71a..ef2ed23ccca37 100644 --- a/activerecord/test/cases/connection_adapters/schema_cache_test.rb +++ b/activerecord/test/cases/connection_adapters/schema_cache_test.rb @@ -86,31 +86,6 @@ def test_cached? assert cache.cached?("courses") end - def test_yaml_dump_and_load - # Create an empty cache. - cache = new_bound_reflection - - tempfile = Tempfile.new(["schema_cache-", ".yml"]) - # Dump it. It should get populated before dumping. - cache.dump_to(tempfile.path) - - reset_deduplicable! - - # Load the cache. - cache = load_bound_reflection(tempfile.path) - - assert_no_queries(include_schema: true) do - assert_equal 3, cache.columns("courses").size - assert_equal 3, cache.columns("courses").map { |column| column.fetch_cast_type(@connection) }.compact.size - assert_equal 3, cache.columns_hash("courses").size - assert cache.data_source_exists?("courses") - assert_equal "id", cache.primary_keys("courses") - assert_equal 1, cache.indexes("courses").size - end - ensure - tempfile.unlink - end - def test_cache_path_can_be_in_directory cache = new_bound_reflection tmp_dir = Dir.mktmpdir @@ -123,44 +98,6 @@ def test_cache_path_can_be_in_directory FileUtils.rm_r(tmp_dir) end - def test_yaml_dump_and_load_with_gzip - # Create an empty cache. - cache = new_bound_reflection - - tempfile = Tempfile.new(["schema_cache-", ".yml.gz"]) - # Dump it. It should get populated before dumping. - cache.dump_to(tempfile.path) - - reset_deduplicable! - - # Unzip and load manually. - cache = Zlib::GzipReader.open(tempfile.path) do |gz| - YAML.respond_to?(:unsafe_load) ? YAML.unsafe_load(gz.read) : YAML.load(gz.read) - end - - assert_no_queries(include_schema: true) do - assert_equal 3, cache.columns(@connection, "courses").size - assert_equal 3, cache.columns(@connection, "courses").map { |column| column.fetch_cast_type(@connection) }.compact.size - assert_equal 3, cache.columns_hash(@connection, "courses").size - assert cache.data_source_exists?(@connection, "courses") - assert_equal "id", cache.primary_keys(@connection, "courses") - assert_equal 1, cache.indexes(@connection, "courses").size - end - - # Load the cache the usual way. - cache = load_bound_reflection(tempfile.path) - - assert_no_queries do - assert_equal 3, cache.columns("courses").size - assert_equal 3, cache.columns_hash("courses").size - assert cache.data_source_exists?("courses") - assert_equal "id", cache.primary_keys("courses") - assert_equal 1, cache.indexes("courses").size - end - ensure - tempfile.unlink - end - def test_yaml_loads_5_1_dump cache = load_bound_reflection(schema_dump_5_1_path) @@ -250,47 +187,6 @@ def test_clearing assert_equal 0, @cache.size end - def test_marshal_dump_and_load - # Create an empty cache. - cache = new_bound_reflection - - # Populate it. - cache.add("courses") - - # Create a new cache by marshal dumping / loading. - cache = Marshal.load(Marshal.dump(cache.instance_variable_get(:@schema_reflection).instance_variable_get(:@cache))) - - assert_no_queries do - assert_equal 3, cache.columns(@connection, "courses").size - assert_equal 3, cache.columns_hash(@connection, "courses").size - assert cache.data_source_exists?(@connection, "courses") - assert_equal "id", cache.primary_keys(@connection, "courses") - assert_equal 1, cache.indexes(@connection, "courses").size - end - end - - def test_marshal_dump_and_load_via_disk - # Create an empty cache. - cache = new_bound_reflection - - tempfile = Tempfile.new(["schema_cache-", ".dump"]) - # Dump it. It should get populated before dumping. - cache.dump_to(tempfile.path) - - # Load a new cache. - cache = load_bound_reflection(tempfile.path) - - assert_no_queries do - assert_equal 3, cache.columns("courses").size - assert_equal 3, cache.columns_hash("courses").size - assert cache.data_source_exists?("courses") - assert_equal "id", cache.primary_keys("courses") - assert_equal 1, cache.indexes("courses").size - end - ensure - tempfile.unlink - end - def test_marshal_dump_and_load_with_ignored_tables old_ignore = ActiveRecord.schema_cache_ignored_tables assert_not ActiveRecord.schema_cache_ignored_table?("professors") @@ -329,39 +225,6 @@ def test_marshal_dump_and_load_with_ignored_tables ActiveRecord.schema_cache_ignored_tables = old_ignore end - def test_marshal_dump_and_load_with_gzip - # Create an empty cache. - cache = new_bound_reflection - - tempfile = Tempfile.new(["schema_cache-", ".dump.gz"]) - # Dump it. It should get populated before dumping. - cache.dump_to(tempfile.path) - - # Load a new cache manually. - cache = Zlib::GzipReader.open(tempfile.path) { |gz| Marshal.load(gz.read) } - - assert_no_queries do - assert_equal 3, cache.columns(@connection, "courses").size - assert_equal 3, cache.columns_hash(@connection, "courses").size - assert cache.data_source_exists?(@connection, "courses") - assert_equal "id", cache.primary_keys(@connection, "courses") - assert_equal 1, cache.indexes(@connection, "courses").size - end - - # Load a new cache. - cache = load_bound_reflection(tempfile.path) - - assert_no_queries do - assert_equal 3, cache.columns("courses").size - assert_equal 3, cache.columns_hash("courses").size - assert cache.data_source_exists?("courses") - assert_equal "id", cache.primary_keys("courses") - assert_equal 1, cache.indexes("courses").size - end - ensure - tempfile.unlink - end - def test_gzip_dumps_identical # Create an empty cache. cache = new_bound_reflection @@ -499,5 +362,145 @@ def schema_dump_8_0_path "#{ASSETS_ROOT}/schema_dump_8_0.yml" end end + + module DumpAndLoadTests + def setup + @pool = ARUnit2Model.connection_pool + + @deduplicable_registries_were = deduplicable_classes.index_with do |klass| + klass.registry.dup + end + end + + def teardown + @deduplicable_registries_were.each do |klass, registry| + klass.registry.clear + klass.registry.merge!(registry) + end + end + + def test_dump_and_load_via_disk + # Create an empty cache. + cache = new_bound_reflection + + tempfile = Tempfile.new(["schema_cache-", format_extension]) + # Dump it. It should get populated before dumping. + cache.dump_to(tempfile.path) + + # Load a new cache. + cache = load_bound_reflection(tempfile.path) + + assert_no_queries(include_schema: true) do + assert_equal 3, cache.columns("courses").size + assert_equal 3, cache.columns("courses").map { |column| column.fetch_cast_type(@pool.lease_connection) }.compact.size + assert_equal 3, cache.columns_hash("courses").size + assert cache.data_source_exists?("courses") + assert_equal "id", cache.primary_keys("courses") + assert_equal 1, cache.indexes("courses").size + end + ensure + tempfile.unlink + end + + def test_dump_and_load_with_gzip + # Create an empty cache. + cache = new_bound_reflection + + tempfile = Tempfile.new(["schema_cache-", "#{format_extension}.gz"]) + # Dump it. It should get populated before dumping. + cache.dump_to(tempfile.path) + + reset_deduplicable! + + # Unzip and load manually. + cache = Zlib::GzipReader.open(tempfile.path) { |gz| load(gz.read) } + + assert_no_queries(include_schema: true) do + assert_equal 3, cache.columns(@pool, "courses").size + assert_equal 3, cache.columns(@pool, "courses").map { |column| column.fetch_cast_type(@pool.lease_connection) }.compact.size + assert_equal 3, cache.columns_hash(@pool, "courses").size + assert cache.data_source_exists?(@pool, "courses") + assert_equal "id", cache.primary_keys(@pool, "courses") + assert_equal 1, cache.indexes(@pool, "courses").size + end + + # Load the cache the usual way. + cache = load_bound_reflection(tempfile.path) + + assert_no_queries(include_schema: true) do + assert_equal 3, cache.columns("courses").size + assert_equal 3, cache.columns("courses").map { |column| column.fetch_cast_type(@pool.lease_connection) }.compact.size + assert_equal 3, cache.columns_hash("courses").size + assert cache.data_source_exists?("courses") + assert_equal "id", cache.primary_keys("courses") + assert_equal 1, cache.indexes("courses").size + end + ensure + tempfile.unlink + end + + private + def new_bound_reflection + BoundSchemaReflection.new(SchemaReflection.new(nil), @pool) + end + + def load_bound_reflection(filename) + reset_deduplicable! + + BoundSchemaReflection.new(SchemaReflection.new(filename), @pool).tap do |cache| + cache.load! + end + end + + def deduplicable_classes + klasses = [ + ActiveRecord::ConnectionAdapters::SqlTypeMetadata, + ActiveRecord::ConnectionAdapters::Column, + ] + + if defined?(ActiveRecord::ConnectionAdapters::PostgreSQL) + klasses << ActiveRecord::ConnectionAdapters::PostgreSQL::TypeMetadata + end + if defined?(ActiveRecord::ConnectionAdapters::MySQL::TypeMetadata) + klasses << ActiveRecord::ConnectionAdapters::MySQL::TypeMetadata + end + + klasses.flat_map do |klass| + [klass] + klass.descendants + end.uniq + end + + def reset_deduplicable! + deduplicable_classes.each do |klass| + klass.registry.clear + end + end + end + + class MarshalFormatTest < ActiveRecord::TestCase + include DumpAndLoadTests + + private + def format_extension + ".dump" + end + + def load(data) + Marshal.load(data) + end + end + + class YamlFormatTest < ActiveRecord::TestCase + include DumpAndLoadTests + + private + def format_extension + ".yml" + end + + def load(data) + YAML.respond_to?(:unsafe_load) ? YAML.unsafe_load(data) : YAML.load(data) + end + end end end From 0b0e0471011cf87977c6b924874788da1e14c2da Mon Sep 17 00:00:00 2001 From: Hartley McGuire Date: Mon, 13 Oct 2025 22:22:02 +0000 Subject: [PATCH 0766/1075] Always use Tempfile.create in SchemaCache tests Previously all of the tests used Tempfile.new, and only some of them would unlink the tempfile after use. This commit refactors all of the tests to use Tempfile.create, which automatically cleans up the tempfiles after use. Additionally, use Object#with to simplify setup/teardown (and its also just easier to compose blocks than trying to mix block/ensure). --- .../connection_adapters/schema_cache_test.rb | 241 +++++++++--------- 1 file changed, 120 insertions(+), 121 deletions(-) diff --git a/activerecord/test/cases/connection_adapters/schema_cache_test.rb b/activerecord/test/cases/connection_adapters/schema_cache_test.rb index ef2ed23ccca37..d5ae4a91b25fa 100644 --- a/activerecord/test/cases/connection_adapters/schema_cache_test.rb +++ b/activerecord/test/cases/connection_adapters/schema_cache_test.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "cases/helper" +require "active_support/core_ext/object/with" module ActiveRecord module ConnectionAdapters @@ -14,11 +15,9 @@ def setup @pool = ARUnit2Model.connection_pool @connection = ARUnit2Model.lease_connection @cache = new_bound_reflection - @check_schema_cache_dump_version_was = SchemaReflection.check_schema_cache_dump_version end def teardown - SchemaReflection.check_schema_cache_dump_version = @check_schema_cache_dump_version_was @deduplicable_registries_were.each do |klass, registry| klass.registry.clear klass.registry.merge!(registry) @@ -67,23 +66,25 @@ def test_cached? cache.columns("courses").size assert cache.cached?("courses") - tempfile = Tempfile.new(["schema_cache-", ".yml"]) - cache.dump_to(tempfile.path) + Tempfile.create(["schema_cache-", ".yml"]) do |tempfile| + cache.dump_to(tempfile.path) - reset_deduplicable! + reset_deduplicable! - reflection = SchemaReflection.new(tempfile.path) + reflection = SchemaReflection.new(tempfile.path) - # `check_schema_cache_dump_version` forces us to have an active connection - # to load the cache. - assert_not reflection.cached?("courses") + # `check_schema_cache_dump_version` forces us to have an active connection + # to load the cache. + assert_not reflection.cached?("courses") - # If we disable it we can load the cache - SchemaReflection.check_schema_cache_dump_version = false - assert reflection.cached?("courses") + # If we disable it we can load the cache + SchemaReflection.with(check_schema_cache_dump_version: false) do + assert reflection.cached?("courses") - cache = BoundSchemaReflection.new(reflection, :__unused_pool__) - assert cache.cached?("courses") + cache = BoundSchemaReflection.new(reflection, :__unused_pool__) + assert cache.cached?("courses") + end + end end def test_cache_path_can_be_in_directory @@ -188,62 +189,62 @@ def test_clearing end def test_marshal_dump_and_load_with_ignored_tables - old_ignore = ActiveRecord.schema_cache_ignored_tables assert_not ActiveRecord.schema_cache_ignored_table?("professors") - ActiveRecord.schema_cache_ignored_tables = ["professors"] - assert ActiveRecord.schema_cache_ignored_table?("professors") - # Create an empty cache. - cache = new_bound_reflection - - tempfile = Tempfile.new(["schema_cache-", ".dump"]) - # Dump it. It should get populated before dumping. - cache.dump_to(tempfile.path) - # Load a new cache. - cache = load_bound_reflection(tempfile.path) - - # Assert a table in the cache - assert cache.data_source_exists?("courses"), "expected posts to be in the cached data_sources" - assert_equal 3, cache.columns("courses").size - assert_equal 3, cache.columns_hash("courses").size - assert cache.data_source_exists?("courses") - assert_equal "id", cache.primary_keys("courses") - assert_equal 1, cache.indexes("courses").size - - # Assert ignored table. Behavior should match non-existent table. - assert_nil cache.data_source_exists?("professors"), "expected comments to not be in the cached data_sources" - assert_raises ActiveRecord::StatementInvalid do - cache.columns("professors") - end - assert_raises ActiveRecord::StatementInvalid do - cache.columns_hash("professors").size + ActiveRecord.with(schema_cache_ignored_tables: ["professors"]) do + assert ActiveRecord.schema_cache_ignored_table?("professors") + # Create an empty cache. + cache = new_bound_reflection + + Tempfile.create(["schema_cache-", ".dump"]) do |tempfile| + # Dump it. It should get populated before dumping. + cache.dump_to(tempfile.path) + + # Load a new cache. + cache = load_bound_reflection(tempfile.path) + + # Assert a table in the cache + assert cache.data_source_exists?("courses"), "expected posts to be in the cached data_sources" + assert_equal 3, cache.columns("courses").size + assert_equal 3, cache.columns_hash("courses").size + assert cache.data_source_exists?("courses") + assert_equal "id", cache.primary_keys("courses") + assert_equal 1, cache.indexes("courses").size + + # Assert ignored table. Behavior should match non-existent table. + assert_nil cache.data_source_exists?("professors"), "expected comments to not be in the cached data_sources" + assert_raises ActiveRecord::StatementInvalid do + cache.columns("professors") + end + assert_raises ActiveRecord::StatementInvalid do + cache.columns_hash("professors").size + end + assert_nil cache.primary_keys("professors") + assert_equal [], cache.indexes("professors") + end end - assert_nil cache.primary_keys("professors") - assert_equal [], cache.indexes("professors") - ensure - tempfile.unlink - ActiveRecord.schema_cache_ignored_tables = old_ignore end def test_gzip_dumps_identical # Create an empty cache. cache = new_bound_reflection - tempfile_a = Tempfile.new(["schema_cache-", ".yml.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-", ".yml.gz"]) - # Dump it. It should get populated before dumping. - cache.dump_to(tempfile_b.path) - digest_b = Digest::MD5.file(tempfile_b).hexdigest + Tempfile.create(["schema_cache-", ".yml.gz"]) do |tempfile_a| + # 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 - assert_equal digest_a, digest_b - ensure - tempfile_a.unlink - tempfile_b.unlink + Tempfile.create(["schema_cache-", ".yml.gz"]) do |tempfile_b| + # 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 + end + end end def test_data_source_exist @@ -279,38 +280,38 @@ def test_clear_data_source_cache unless in_memory_db? def test_when_lazily_load_schema_cache_is_set_cache_is_lazily_populated_when_est_connection - tempfile = Tempfile.new(["schema_cache-", ".yml"]) - original_config = ActiveRecord::Base.configurations.configs_for(env_name: "arunit2", name: "primary") - new_config = original_config.configuration_hash.merge(schema_cache_path: tempfile.path) + Tempfile.create(["schema_cache-", ".yml"]) do |tempfile| + original_config = ActiveRecord::Base.configurations.configs_for(env_name: "arunit2", name: "primary") + new_config = original_config.configuration_hash.merge(schema_cache_path: tempfile.path) - ActiveRecord::Base.establish_connection(new_config) + ActiveRecord::Base.establish_connection(new_config) - # cache starts empty - assert_nil ActiveRecord::Base.connection_pool.schema_reflection.instance_variable_get(:@cache) + # cache starts empty + assert_nil ActiveRecord::Base.connection_pool.schema_reflection.instance_variable_get(:@cache) - # now we access the cache, causing it to load - assert_not_nil ActiveRecord::Base.schema_cache.version + # now we access the cache, causing it to load + assert_not_nil ActiveRecord::Base.schema_cache.version - assert File.exist?(tempfile) - assert_not_nil ActiveRecord::Base.connection_pool.schema_reflection.instance_variable_get(:@cache) + assert File.exist?(tempfile) + assert_not_nil ActiveRecord::Base.connection_pool.schema_reflection.instance_variable_get(:@cache) - # assert cache is still empty on new connection (precondition for the - # following to show it is loading because of the config change) - ActiveRecord::Base.establish_connection(new_config) + # assert cache is still empty on new connection (precondition for the + # following to show it is loading because of the config change) + ActiveRecord::Base.establish_connection(new_config) - assert File.exist?(tempfile) - assert_nil ActiveRecord::Base.connection_pool.schema_reflection.instance_variable_get(:@cache) + assert File.exist?(tempfile) + assert_nil ActiveRecord::Base.connection_pool.schema_reflection.instance_variable_get(:@cache) - # cache is loaded upon connection when lazily loading is on - old_config = ActiveRecord.lazily_load_schema_cache - ActiveRecord.lazily_load_schema_cache = true - ActiveRecord::Base.establish_connection(new_config) - ActiveRecord::Base.connection_pool.lease_connection.verify! + # cache is loaded upon connection when lazily loading is on + ActiveRecord.with(lazily_load_schema_cache: true) do + ActiveRecord::Base.establish_connection(new_config) + ActiveRecord::Base.connection_pool.lease_connection.verify! - assert File.exist?(tempfile) - assert_not_nil ActiveRecord::Base.connection_pool.schema_reflection.instance_variable_get(:@cache) + assert File.exist?(tempfile) + assert_not_nil ActiveRecord::Base.connection_pool.schema_reflection.instance_variable_get(:@cache) + end + end ensure - ActiveRecord.lazily_load_schema_cache = old_config ActiveRecord::Base.establish_connection(:arunit) end end @@ -383,60 +384,58 @@ def test_dump_and_load_via_disk # Create an empty cache. cache = new_bound_reflection - tempfile = Tempfile.new(["schema_cache-", format_extension]) - # Dump it. It should get populated before dumping. - cache.dump_to(tempfile.path) + Tempfile.create(["schema_cache-", format_extension]) do |tempfile| + # Dump it. It should get populated before dumping. + cache.dump_to(tempfile.path) - # Load a new cache. - cache = load_bound_reflection(tempfile.path) + # Load a new cache. + cache = load_bound_reflection(tempfile.path) - assert_no_queries(include_schema: true) do - assert_equal 3, cache.columns("courses").size - assert_equal 3, cache.columns("courses").map { |column| column.fetch_cast_type(@pool.lease_connection) }.compact.size - assert_equal 3, cache.columns_hash("courses").size - assert cache.data_source_exists?("courses") - assert_equal "id", cache.primary_keys("courses") - assert_equal 1, cache.indexes("courses").size + assert_no_queries(include_schema: true) do + assert_equal 3, cache.columns("courses").size + assert_equal 3, cache.columns("courses").map { |column| column.fetch_cast_type(@pool.lease_connection) }.compact.size + assert_equal 3, cache.columns_hash("courses").size + assert cache.data_source_exists?("courses") + assert_equal "id", cache.primary_keys("courses") + assert_equal 1, cache.indexes("courses").size + end end - ensure - tempfile.unlink end def test_dump_and_load_with_gzip # Create an empty cache. cache = new_bound_reflection - tempfile = Tempfile.new(["schema_cache-", "#{format_extension}.gz"]) - # Dump it. It should get populated before dumping. - cache.dump_to(tempfile.path) + Tempfile.create(["schema_cache-", "#{format_extension}.gz"]) do |tempfile| + # Dump it. It should get populated before dumping. + cache.dump_to(tempfile.path) - reset_deduplicable! + reset_deduplicable! - # Unzip and load manually. - cache = Zlib::GzipReader.open(tempfile.path) { |gz| load(gz.read) } + # Unzip and load manually. + cache = Zlib::GzipReader.open(tempfile.path) { |gz| load(gz.read) } - assert_no_queries(include_schema: true) do - assert_equal 3, cache.columns(@pool, "courses").size - assert_equal 3, cache.columns(@pool, "courses").map { |column| column.fetch_cast_type(@pool.lease_connection) }.compact.size - assert_equal 3, cache.columns_hash(@pool, "courses").size - assert cache.data_source_exists?(@pool, "courses") - assert_equal "id", cache.primary_keys(@pool, "courses") - assert_equal 1, cache.indexes(@pool, "courses").size - end + assert_no_queries(include_schema: true) do + assert_equal 3, cache.columns(@pool, "courses").size + assert_equal 3, cache.columns(@pool, "courses").map { |column| column.fetch_cast_type(@pool.lease_connection) }.compact.size + assert_equal 3, cache.columns_hash(@pool, "courses").size + assert cache.data_source_exists?(@pool, "courses") + assert_equal "id", cache.primary_keys(@pool, "courses") + assert_equal 1, cache.indexes(@pool, "courses").size + end - # Load the cache the usual way. - cache = load_bound_reflection(tempfile.path) + # Load the cache the usual way. + cache = load_bound_reflection(tempfile.path) - assert_no_queries(include_schema: true) do - assert_equal 3, cache.columns("courses").size - assert_equal 3, cache.columns("courses").map { |column| column.fetch_cast_type(@pool.lease_connection) }.compact.size - assert_equal 3, cache.columns_hash("courses").size - assert cache.data_source_exists?("courses") - assert_equal "id", cache.primary_keys("courses") - assert_equal 1, cache.indexes("courses").size + assert_no_queries(include_schema: true) do + assert_equal 3, cache.columns("courses").size + assert_equal 3, cache.columns("courses").map { |column| column.fetch_cast_type(@pool.lease_connection) }.compact.size + assert_equal 3, cache.columns_hash("courses").size + assert cache.data_source_exists?("courses") + assert_equal "id", cache.primary_keys("courses") + assert_equal 1, cache.indexes("courses").size + end end - ensure - tempfile.unlink end private From f52a99103e68f6101dced1ac4fe2600837e24d95 Mon Sep 17 00:00:00 2001 From: Hartley McGuire Date: Mon, 13 Oct 2025 22:26:56 +0000 Subject: [PATCH 0767/1075] Remove unused params from helper signatures pool is never passed into these methods, so we can just always use the instance variable. Additionally, new_bound_reflection can be used inside load_bound_reflection to reduce duplication. --- .../cases/connection_adapters/schema_cache_test.rb | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/activerecord/test/cases/connection_adapters/schema_cache_test.rb b/activerecord/test/cases/connection_adapters/schema_cache_test.rb index d5ae4a91b25fa..2b4d378482c2d 100644 --- a/activerecord/test/cases/connection_adapters/schema_cache_test.rb +++ b/activerecord/test/cases/connection_adapters/schema_cache_test.rb @@ -24,13 +24,13 @@ def teardown end end - def new_bound_reflection(pool = @pool) - BoundSchemaReflection.new(SchemaReflection.new(nil), pool) + def new_bound_reflection(filename = nil) + BoundSchemaReflection.new(SchemaReflection.new(filename), @pool) end - def load_bound_reflection(filename, pool = @pool) + def load_bound_reflection(filename) reset_deduplicable! - BoundSchemaReflection.new(SchemaReflection.new(filename), pool).tap do |cache| + new_bound_reflection(filename).tap do |cache| cache.load! end end @@ -439,14 +439,14 @@ def test_dump_and_load_with_gzip end private - def new_bound_reflection - BoundSchemaReflection.new(SchemaReflection.new(nil), @pool) + def new_bound_reflection(filename = nil) + BoundSchemaReflection.new(SchemaReflection.new(filename), @pool) end def load_bound_reflection(filename) reset_deduplicable! - BoundSchemaReflection.new(SchemaReflection.new(filename), @pool).tap do |cache| + new_bound_reflection(filename).tap do |cache| cache.load! end end From 705e260f34c45465c30c312f2c27eea343f0d380 Mon Sep 17 00:00:00 2001 From: Yasuo Honda Date: Tue, 14 Oct 2025 20:01:46 +0900 Subject: [PATCH 0768/1075] Revert "Skip ApiAppGeneratorTest and AppGeneratorTest for Ruby 3.5.0dev tentatively" --- railties/test/generators/api_app_generator_test.rb | 2 -- railties/test/generators/app_generator_test.rb | 4 ---- 2 files changed, 6 deletions(-) diff --git a/railties/test/generators/api_app_generator_test.rb b/railties/test/generators/api_app_generator_test.rb index aa3a70d11df12..7686c81fa7464 100644 --- a/railties/test/generators/api_app_generator_test.rb +++ b/railties/test/generators/api_app_generator_test.rb @@ -134,8 +134,6 @@ def test_app_update_does_not_generate_unnecessary_bin_files end def test_app_update_does_not_generate_public_files - skip "bundler-audit does not support Ruby 3.5.0dev yet" unless Gem.loaded_specs.key?("bundler-audit") - run_generator run_app_update diff --git a/railties/test/generators/app_generator_test.rb b/railties/test/generators/app_generator_test.rb index e148d87ea4625..f5390542b4c15 100644 --- a/railties/test/generators/app_generator_test.rb +++ b/railties/test/generators/app_generator_test.rb @@ -97,10 +97,6 @@ class AppGeneratorTest < Rails::Generators::TestCase # brings setup, teardown, and some tests include SharedGeneratorTests - setup do - skip "bundler-audit does not support Ruby 3.5.0dev yet" unless Gem.loaded_specs.key?("bundler-audit") - end - def default_files ::DEFAULT_APP_FILES end From d6f9f62d49750fee6a45f3672e2227d5c4198a62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Tue, 14 Oct 2025 23:34:43 +0000 Subject: [PATCH 0769/1075] Make the Structured Event Subscriber emit events in format that are useful for logging Related to #55900. --- .../structured_event_subscriber.rb | 4 +- .../test/structured_event_subscriber_test.rb | 2 +- .../structured_event_subscriber.rb | 11 +-- .../structured_event_subscriber.rb | 3 +- .../structured_event_subscriber_test.rb | 13 +++- .../structured_event_subscriber.rb | 17 ++--- .../structured_event_subscriber_test.rb | 68 +++++++++---------- .../active_job/structured_event_subscriber.rb | 13 ++-- .../cases/structured_event_subscriber_test.rb | 12 ++-- .../structured_event_subscriber.rb | 1 + .../cases/structured_event_subscriber_test.rb | 4 +- .../structured_event_subscriber.rb | 9 +++ .../test/structured_event_subscriber_test.rb | 36 +++++++--- 13 files changed, 115 insertions(+), 78 deletions(-) diff --git a/actionmailer/lib/action_mailer/structured_event_subscriber.rb b/actionmailer/lib/action_mailer/structured_event_subscriber.rb index cc5811a255fd2..f9ae6c8c440ca 100644 --- a/actionmailer/lib/action_mailer/structured_event_subscriber.rb +++ b/actionmailer/lib/action_mailer/structured_event_subscriber.rb @@ -9,7 +9,7 @@ def deliver(event) exception = event.payload[:exception_object] payload = { message_id: event.payload[:message_id], - duration: event.duration.round(2), + duration_ms: event.duration.round(2), mail: event.payload[:mail], perform_deliveries: event.payload[:perform_deliveries], } @@ -28,7 +28,7 @@ def process(event) emit_debug_event("action_mailer.processed", mailer: event.payload[:mailer], action: event.payload[:action], - duration: event.duration.round(2), + duration_ms: event.duration.round(2), ) end debug_only :process diff --git a/actionmailer/test/structured_event_subscriber_test.rb b/actionmailer/test/structured_event_subscriber_test.rb index e6cdefc96a0ef..d1cd1f5540e2f 100644 --- a/actionmailer/test/structured_event_subscriber_test.rb +++ b/actionmailer/test/structured_event_subscriber_test.rb @@ -29,7 +29,7 @@ def test_deliver_is_notified BaseMailer.welcome(message_id: "123@abc").deliver_now end - assert event[:payload][:duration] > 0 + assert event[:payload][:duration_ms] > 0 ensure BaseMailer.deliveries.clear end diff --git a/actionpack/lib/action_controller/structured_event_subscriber.rb b/actionpack/lib/action_controller/structured_event_subscriber.rb index 264bd8ebdbe86..32bdbad8fe5c4 100644 --- a/actionpack/lib/action_controller/structured_event_subscriber.rb +++ b/actionpack/lib/action_controller/structured_event_subscriber.rb @@ -68,16 +68,9 @@ def unpermitted_parameters(event) unpermitted_keys = event.payload[:keys] context = event.payload[:context] - params = {} - context[:params].each_pair do |k, v| - params[k] = v unless INTERNAL_PARAMS.include?(k) - end - emit_debug_event("action_controller.unpermitted_parameters", - controller: context[:controller], - action: context[:action], unpermitted_keys:, - params: + context: context.except(:request) ) end debug_only :unpermitted_parameters @@ -100,8 +93,6 @@ def expire_fragment(event) private def fragment_cache(method_name, event) - return unless ActionController::Base.enable_fragment_cache_logging - key = ActiveSupport::Cache.expand_cache_key(event.payload[:key] || event.payload[:path]) emit_event("action_controller.fragment_cache", diff --git a/actionpack/lib/action_dispatch/structured_event_subscriber.rb b/actionpack/lib/action_dispatch/structured_event_subscriber.rb index 12c536a0d1c70..6cb3ccea2ff54 100644 --- a/actionpack/lib/action_dispatch/structured_event_subscriber.rb +++ b/actionpack/lib/action_dispatch/structured_event_subscriber.rb @@ -10,7 +10,8 @@ def redirect(event) location: payload[:location], status: status, status_name: Rack::Utils::HTTP_STATUS_CODES[status], - duration_ms: event.duration.round(2) + duration_ms: event.duration.round(2), + source_location: payload[:source_location] }) end end diff --git a/actionpack/test/controller/structured_event_subscriber_test.rb b/actionpack/test/controller/structured_event_subscriber_test.rb index 22d5c6cc6524f..71519c75ad3a3 100644 --- a/actionpack/test/controller/structured_event_subscriber_test.rb +++ b/actionpack/test/controller/structured_event_subscriber_test.rb @@ -131,10 +131,17 @@ def test_send_file def test_unpermitted_parameters with_debug_event_reporting do assert_event_reported("action_controller.unpermitted_parameters", payload: { - controller: Another::StructuredEventSubscribersController.name, - action: "unpermitted_parameters", unpermitted_keys: ["age"], - params: { "name" => "John", "age" => "30" } + context: { + params: { + "name" => "John", + "age" => "30", + "controller" => "action_controller/structured_event_subscriber_test/another/structured_event_subscribers", + "action" => "unpermitted_parameters", + }, + controller: Another::StructuredEventSubscribersController.name, + action: "unpermitted_parameters" + } }) do post :unpermitted_parameters, params: { name: "John", age: 30 } end diff --git a/actionview/lib/action_view/structured_event_subscriber.rb b/actionview/lib/action_view/structured_event_subscriber.rb index 79c001239a1eb..0b16b944bf3db 100644 --- a/actionview/lib/action_view/structured_event_subscriber.rb +++ b/actionview/lib/action_view/structured_event_subscriber.rb @@ -15,8 +15,8 @@ def render_template(event) emit_debug_event("action_view.render_template", identifier: from_rails_root(event.payload[:identifier]), layout: from_rails_root(event.payload[:layout]), - duration: event.duration.round(2), - gc: event.gc_time.round(2), + duration_ms: event.duration.round(2), + gc_ms: event.gc_time.round(2), ) end debug_only :render_template @@ -25,8 +25,8 @@ def render_partial(event) emit_debug_event("action_view.render_partial", identifier: from_rails_root(event.payload[:identifier]), layout: from_rails_root(event.payload[:layout]), - duration: event.duration.round(2), - gc: event.gc_time.round(2), + duration_ms: event.duration.round(2), + gc_ms: event.gc_time.round(2), cache_hit: event.payload[:cache_hit], ) end @@ -35,8 +35,8 @@ def render_partial(event) def render_layout(event) emit_event("action_view.render_layout", identifier: from_rails_root(event.payload[:identifier]), - duration: event.duration.round(2), - gc: event.gc_time.round(2), + duration_ms: event.duration.round(2), + gc_ms: event.gc_time.round(2), ) end debug_only :render_layout @@ -45,8 +45,8 @@ def render_collection(event) emit_debug_event("action_view.render_collection", identifier: from_rails_root(event.payload[:identifier] || "templates"), layout: from_rails_root(event.payload[:layout]), - duration: event.duration.round(2), - gc: event.gc_time.round(2), + duration_ms: event.duration.round(2), + gc_ms: event.gc_time.round(2), cache_hits: event.payload[:cache_hits], count: event.payload[:count], ) @@ -75,6 +75,7 @@ class Start # :nodoc: def start(name, id, payload) ActiveSupport.event_reporter.debug("action_view.render_start", + is_layout: name == "render_layout.action_view", identifier: from_rails_root(payload[:identifier]), layout: from_rails_root(payload[:layout]), ) diff --git a/actionview/test/template/structured_event_subscriber_test.rb b/actionview/test/template/structured_event_subscriber_test.rb index bfbe1259acad2..9e079220c337d 100644 --- a/actionview/test/template/structured_event_subscriber_test.rb +++ b/actionview/test/template/structured_event_subscriber_test.rb @@ -41,8 +41,8 @@ def test_render_template @view.render(template: "test/hello_world") end - assert(event[:payload][:gc] >= 0) - assert(event[:payload][:duration] >= 0) + assert(event[:payload][:gc_ms] >= 0) + assert(event[:payload][:duration_ms] >= 0) end end end @@ -57,8 +57,8 @@ def test_render_template_with_layout @view.render(template: "test/hello_world", layout: "layouts/yield") end - assert(event[:payload][:gc] >= 0) - assert(event[:payload][:duration] >= 0) + assert(event[:payload][:gc_ms] >= 0) + assert(event[:payload][:duration_ms] >= 0) end end end @@ -72,8 +72,8 @@ def test_render_file_template @view.render(file: "#{FIXTURE_LOAD_PATH}/test/hello_world.erb") end - assert(event[:payload][:gc] >= 0) - assert(event[:payload][:duration] >= 0) + assert(event[:payload][:gc_ms] >= 0) + assert(event[:payload][:duration_ms] >= 0) end end end @@ -87,8 +87,8 @@ def test_render_text_template @view.render(plain: "TEXT") end - assert(event[:payload][:gc] >= 0) - assert(event[:payload][:duration] >= 0) + assert(event[:payload][:gc_ms] >= 0) + assert(event[:payload][:duration_ms] >= 0) end end end @@ -102,8 +102,8 @@ def test_render_inline_template @view.render(inline: "<%= 'TEXT' %>") end - assert(event[:payload][:gc] >= 0) - assert(event[:payload][:duration] >= 0) + assert(event[:payload][:gc_ms] >= 0) + assert(event[:payload][:duration_ms] >= 0) end end end @@ -117,8 +117,8 @@ def test_render_partial_with_implicit_path @view.render(Customer.new("david"), greeting: "hi") end - assert(event[:payload][:gc] >= 0) - assert(event[:payload][:duration] >= 0) + assert(event[:payload][:gc_ms] >= 0) + assert(event[:payload][:duration_ms] >= 0) end end end @@ -134,8 +134,8 @@ def test_render_partial_with_cache_is_missed @view.render(partial: "test/cached_customer", locals: { cached_customer: Customer.new("david") }) end - assert(event[:payload][:gc] >= 0) - assert(event[:payload][:duration] >= 0) + assert(event[:payload][:gc_ms] >= 0) + assert(event[:payload][:duration_ms] >= 0) end end end @@ -154,8 +154,8 @@ def test_render_partial_with_cache_is_hit @view.render(partial: "test/cached_customer", locals: { cached_customer: Customer.new("david") }) end - assert(event[:payload][:gc] >= 0) - assert(event[:payload][:duration] >= 0) + assert(event[:payload][:gc_ms] >= 0) + assert(event[:payload][:duration_ms] >= 0) end end end @@ -170,8 +170,8 @@ def test_render_partial_as_layout @view.render(layout: "layouts/yield_only") { "hello" } end - assert(event[:payload][:gc] >= 0) - assert(event[:payload][:duration] >= 0) + assert(event[:payload][:gc_ms] >= 0) + assert(event[:payload][:duration_ms] >= 0) end end end @@ -187,8 +187,8 @@ def test_render_partial_with_layout @view.render(partial: "partial", layout: "layouts/yield_only") end - assert(event[:payload][:gc] >= 0) - assert(event[:payload][:duration] >= 0) + assert(event[:payload][:gc_ms] >= 0) + assert(event[:payload][:duration_ms] >= 0) end end end @@ -203,8 +203,8 @@ def test_render_uncached_outer_partial_with_inner_cached_partial_wont_mix_cache_ @view.render(partial: "test/nested_cached_customer", locals: { cached_customer: Customer.new("Stan") }) end - assert(event[:payload][:gc] >= 0) - assert(event[:payload][:duration] >= 0) + assert(event[:payload][:gc_ms] >= 0) + assert(event[:payload][:duration_ms] >= 0) payload = { identifier: "test/_cached_customer.erb", layout: nil, cache_hit: :hit } event = assert_event_reported("action_view.render_partial", payload:) do @@ -212,8 +212,8 @@ def test_render_uncached_outer_partial_with_inner_cached_partial_wont_mix_cache_ @view.render(partial: "test/nested_cached_customer", locals: { cached_customer: Customer.new("Stan") }) end - assert(event[:payload][:gc] >= 0) - assert(event[:payload][:duration] >= 0) + assert(event[:payload][:gc_ms] >= 0) + assert(event[:payload][:duration_ms] >= 0) end end end @@ -269,8 +269,8 @@ def test_render_collection_template @view.render(partial: "test/customer", collection: [ Customer.new("david"), Customer.new("mary") ]) end - assert(event[:payload][:gc] >= 0) - assert(event[:payload][:duration] >= 0) + assert(event[:payload][:gc_ms] >= 0) + assert(event[:payload][:duration_ms] >= 0) end end end @@ -284,8 +284,8 @@ def test_render_collection_template_with_layout @view.render(partial: "test/customer", layout: "layouts/yield_only", collection: [ Customer.new("david"), Customer.new("mary") ]) end - assert(event[:payload][:gc] >= 0) - assert(event[:payload][:duration] >= 0) + assert(event[:payload][:gc_ms] >= 0) + assert(event[:payload][:duration_ms] >= 0) end end end @@ -299,8 +299,8 @@ def test_render_collection_with_implicit_path @view.render([ Customer.new("david"), Customer.new("mary") ], greeting: "hi") end - assert(event[:payload][:gc] >= 0) - assert(event[:payload][:duration] >= 0) + assert(event[:payload][:gc_ms] >= 0) + assert(event[:payload][:duration_ms] >= 0) end end end @@ -314,8 +314,8 @@ def test_render_collection_template_without_path @view.render([ GoodCustomer.new("david"), Customer.new("mary") ], greeting: "hi") end - assert(event[:payload][:gc] >= 0) - assert(event[:payload][:duration] >= 0) + assert(event[:payload][:gc_ms] >= 0) + assert(event[:payload][:duration_ms] >= 0) end end end @@ -331,8 +331,8 @@ def test_render_collection_with_cached_set locals: { greeting: "hi" }) end - assert(event[:payload][:gc] >= 0) - assert(event[:payload][:duration] >= 0) + assert(event[:payload][:gc_ms] >= 0) + assert(event[:payload][:duration_ms] >= 0) end end end diff --git a/activejob/lib/active_job/structured_event_subscriber.rb b/activejob/lib/active_job/structured_event_subscriber.rb index 764fdada2e374..97f2ed6216c92 100644 --- a/activejob/lib/active_job/structured_event_subscriber.rb +++ b/activejob/lib/active_job/structured_event_subscriber.rb @@ -6,11 +6,13 @@ module ActiveJob class StructuredEventSubscriber < ActiveSupport::StructuredEventSubscriber # :nodoc: def enqueue(event) job = event.payload[:job] + adapter = event.payload[:adapter] exception = event.payload[:exception_object] || job.enqueue_error payload = { job_class: job.class.name, job_id: job.job_id, queue: job.queue_name, + adapter: ActiveJob.adapter_name(adapter), aborted: event.payload[:aborted], } @@ -28,12 +30,14 @@ def enqueue(event) def enqueue_at(event) job = event.payload[:job] + adapter = event.payload[:adapter] exception = event.payload[:exception_object] || job.enqueue_error payload = { job_class: job.class.name, job_id: job.job_id, queue: job.queue_name, scheduled_at: job.scheduled_at, + adapter: ActiveJob.adapter_name(adapter), aborted: event.payload[:aborted], } @@ -46,7 +50,7 @@ def enqueue_at(event) payload[:arguments] = job.arguments end - emit_event("active_job.enqueued", payload) + emit_event("active_job.enqueued_at", payload) end def enqueue_all(event) @@ -57,10 +61,10 @@ def enqueue_all(event) emit_event("active_job.bulk_enqueued", adapter: ActiveJob.adapter_name(adapter), - total_jobs: jobs.size, + job_count: jobs.size, enqueued_count: enqueued_count, - failed_count: failed_count, - job_classes: jobs.map { |job| job.class.name }.tally + failed_enqueue_count: failed_count, + enqueued_classes: jobs.filter_map { |job| job.class.name }.tally ) end @@ -92,6 +96,7 @@ def perform(event) if exception payload[:exception_class] = exception.class.name payload[:exception_message] = exception.message + payload[:exception_backtrace] = exception.backtrace end emit_event("active_job.completed", payload) diff --git a/activejob/test/cases/structured_event_subscriber_test.rb b/activejob/test/cases/structured_event_subscriber_test.rb index 4d31b1ef26d80..2b3282941e232 100644 --- a/activejob/test/cases/structured_event_subscriber_test.rb +++ b/activejob/test/cases/structured_event_subscriber_test.rb @@ -72,7 +72,8 @@ def perform(action:) def test_enqueue_job event = assert_event_reported("active_job.enqueued", payload: { job_class: TestJob.name, - queue: "default" + queue: "default", + adapter: ActiveJob.adapter_name(ActiveJob::Base.queue_adapter) }) do TestJob.perform_later end @@ -110,8 +111,9 @@ def test_enqueue_job_with_arguments_with_log_arguments_false def test_enqueue_at_job scheduled_time = 1.hour.from_now - event = assert_event_reported("active_job.enqueued", payload: { + event = assert_event_reported("active_job.enqueued_at", payload: { job_class: TestJob.name, + adapter: ActiveJob.adapter_name(ActiveJob::Base.queue_adapter), queue: "default" }) do TestJob.set(wait_until: scheduled_time).perform_later @@ -245,10 +247,10 @@ def test_bulk_enqueue_jobs assert_event_reported("active_job.bulk_enqueued", payload: { adapter: ActiveJob.adapter_name(ActiveJob::Base.queue_adapter), - total_jobs: 3, + job_count: 3, enqueued_count: 3, - failed_count: 0, - job_classes: { TestJob.name => 3 } + failed_enqueue_count: 0, + enqueued_classes: { TestJob.name => 3 }, }) do ActiveJob.perform_all_later(jobs) end diff --git a/activerecord/lib/active_record/structured_event_subscriber.rb b/activerecord/lib/active_record/structured_event_subscriber.rb index da50e6d46b15a..d2d72882cf11f 100644 --- a/activerecord/lib/active_record/structured_event_subscriber.rb +++ b/activerecord/lib/active_record/structured_event_subscriber.rb @@ -51,6 +51,7 @@ def sql(event) cached: payload[:cached], lock_wait: payload[:lock_wait], binds: binds, + duration_ms: event.duration.round(2), ) end debug_only :sql diff --git a/activerecord/test/cases/structured_event_subscriber_test.rb b/activerecord/test/cases/structured_event_subscriber_test.rb index b219968e0c254..5ed1676d34513 100644 --- a/activerecord/test/cases/structured_event_subscriber_test.rb +++ b/activerecord/test/cases/structured_event_subscriber_test.rb @@ -53,12 +53,14 @@ def test_explain_statements_are_ignored end def test_basic_query_logging - assert_event_reported("active_record.sql", payload: { + event = assert_event_reported("active_record.sql", payload: { name: "Developer Load", sql: /SELECT .*?FROM .?developers.?/i, }) do Developer.all.load end + + assert(event[:payload][:duration_ms] > 0) end def test_async_query diff --git a/activestorage/lib/active_storage/structured_event_subscriber.rb b/activestorage/lib/active_storage/structured_event_subscriber.rb index 59f55dc8a65bc..361734d75ca1b 100644 --- a/activestorage/lib/active_storage/structured_event_subscriber.rb +++ b/activestorage/lib/active_storage/structured_event_subscriber.rb @@ -8,36 +8,42 @@ def service_upload(event) emit_event("active_storage.service_upload", key: event.payload[:key], checksum: event.payload[:checksum], + duration_ms: event.duration.round(2), ) end def service_download(event) emit_event("active_storage.service_download", key: event.payload[:key], + duration_ms: event.duration.round(2), ) end def service_streaming_download(event) emit_event("active_storage.service_streaming_download", key: event.payload[:key], + duration_ms: event.duration.round(2), ) end def preview(event) emit_event("active_storage.preview", key: event.payload[:key], + duration_ms: event.duration.round(2), ) end def service_delete(event) emit_event("active_storage.service_delete", key: event.payload[:key], + duration_ms: event.duration.round(2), ) end def service_delete_prefixed(event) emit_event("active_storage.service_delete_prefixed", prefix: event.payload[:prefix], + duration_ms: event.duration.round(2), ) end @@ -45,6 +51,7 @@ def service_exist(event) emit_debug_event("active_storage.service_exist", key: event.payload[:key], exist: event.payload[:exist], + duration_ms: event.duration.round(2), ) end debug_only :service_exist @@ -53,6 +60,7 @@ def service_url(event) emit_debug_event("active_storage.service_url", key: event.payload[:key], url: event.payload[:url], + duration_ms: event.duration.round(2), ) end debug_only :service_url @@ -61,6 +69,7 @@ def service_mirror(event) emit_debug_event("active_storage.service_mirror", key: event.payload[:key], checksum: event.payload[:checksum], + duration_ms: event.duration.round(2), ) end debug_only :service_mirror diff --git a/activestorage/test/structured_event_subscriber_test.rb b/activestorage/test/structured_event_subscriber_test.rb index e0484c1ef736b..22293d8469bed 100644 --- a/activestorage/test/structured_event_subscriber_test.rb +++ b/activestorage/test/structured_event_subscriber_test.rb @@ -10,76 +10,92 @@ class StructuredEventSubscriberTest < ActiveSupport::TestCase include ActiveSupport::Testing::EventReporterAssertions test "service_upload" do - assert_event_reported("active_storage.service_upload", payload: { key: /.*/, checksum: /.*/ }) do + event = assert_event_reported("active_storage.service_upload", payload: { key: /.*/, checksum: /.*/ }) do User.create!(name: "Test", avatar: { io: StringIO.new, filename: "avatar.jpg" }) end + + assert(event[:payload][:duration_ms] > 0) end test "service_download" do blob = create_blob(filename: "avatar.jpg") user = User.create!(name: "Test", avatar: blob) - assert_event_reported("active_storage.service_download", payload: { key: user.avatar.key }) do + event = assert_event_reported("active_storage.service_download", payload: { key: user.avatar.key }) do user.avatar.download end + + assert(event[:payload][:duration_ms] > 0) end test "service_streaming_download" do blob = create_blob(filename: "avatar.jpg") user = User.create!(name: "Test", avatar: blob) - assert_event_reported("active_storage.service_streaming_download", payload: { key: user.avatar.key }) do + event = assert_event_reported("active_storage.service_streaming_download", payload: { key: user.avatar.key }) do user.avatar.download { } end + + assert(event[:payload][:duration_ms] > 0) end test "preview" do blob = create_file_blob(filename: "cropped.pdf", content_type: "application/pdf") user = User.create!(name: "Test", avatar: blob) - assert_event_reported("active_storage.preview", payload: { key: user.avatar.key }) do + event = assert_event_reported("active_storage.preview", payload: { key: user.avatar.key }) do user.avatar.preview(resize_to_limit: [640, 280]).processed end + + assert(event[:payload][:duration_ms] > 0) end test "service_delete" do blob = create_blob(filename: "avatar.jpg") user = User.create!(name: "Test", avatar: blob) - assert_event_reported("active_storage.service_delete", payload: { key: user.avatar.key }) do + event = assert_event_reported("active_storage.service_delete", payload: { key: user.avatar.key }) do user.avatar.purge end + + assert(event[:payload][:duration_ms] > 0) end test "service_delete_prefixed" do blob = create_file_blob(fixture: "colors.bmp") user = User.create!(name: "Test", avatar: blob) - assert_event_reported("active_storage.service_delete_prefixed", payload: { prefix: /variants\/.*/ }) do + event = assert_event_reported("active_storage.service_delete_prefixed", payload: { prefix: /variants\/.*/ }) do user.avatar.purge end + + assert(event[:payload][:duration_ms] > 0) end test "service_exist" do blob = create_blob(filename: "avatar.jpg") user = User.create!(name: "Test", avatar: blob) - with_debug_event_reporting do + event = with_debug_event_reporting do assert_event_reported("active_storage.service_exist", payload: { key: /.*/, exist: true }) do user.avatar.service.exist? user.avatar.key end end + + assert(event[:payload][:duration_ms] > 0) end test "service_url" do blob = create_blob(filename: "avatar.jpg") user = User.create!(name: "Test", avatar: blob) - with_debug_event_reporting do + event = with_debug_event_reporting do assert_event_reported("active_storage.service_url", payload: { key: /.*/, url: /.*/ }) do user.avatar.url end end + + assert(event[:payload][:duration_ms] > 0) end test "service_mirror" do @@ -98,11 +114,13 @@ class StructuredEventSubscriberTest < ActiveSupport::TestCase service = ActiveStorage::Service.configure :mirror, config service.upload blob.key, StringIO.new(blob.download), checksum: blob.checksum - with_debug_event_reporting do + event = with_debug_event_reporting do assert_event_reported("active_storage.service_mirror", payload: { key: /.*/, url: /.*/ }) do service.mirror blob.key, checksum: blob.checksum end end + + assert(event[:payload][:duration_ms] > 0) end end end From 4a753c2f79a9c41ec3ab5551724599193da180e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Tue, 14 Oct 2025 23:37:40 +0000 Subject: [PATCH 0770/1075] Revert "Merge pull request #54040 from mrpasquini/md5_config" This reverts commit e64e5d31cdeafee142e32e604b513a39de779651, reversing changes made to 09897458117eac16b0bb6f60d325a8630ebe4ef8. It isn't very useful to change the MD5 implementation. We should allow users to configure the digest class to be MD5, SHA1, SHA256, etc. --- activestorage/CHANGELOG.md | 7 ------- activestorage/app/models/active_storage/blob.rb | 2 +- activestorage/lib/active_storage.rb | 9 --------- activestorage/lib/active_storage/downloader.rb | 2 +- activestorage/lib/active_storage/engine.rb | 3 --- .../lib/active_storage/service/disk_service.rb | 2 +- .../direct_uploads_controller_test.rb | 8 ++++---- .../test/controllers/disk_controller_test.rb | 10 +++++----- activestorage/test/models/attachment_test.rb | 2 +- activestorage/test/models/blob_test.rb | 4 ++-- .../test/service/gcs_public_service_test.rb | 2 +- activestorage/test/service/gcs_service_test.rb | 10 +++++----- .../test/service/mirror_service_test.rb | 6 +++--- .../test/service/s3_public_service_test.rb | 2 +- activestorage/test/service/s3_service_test.rb | 16 ++++++++-------- .../test/service/shared_service_tests.rb | 6 +++--- activestorage/test/test_helper.rb | 2 +- guides/source/configuring.md | 9 --------- 18 files changed, 37 insertions(+), 65 deletions(-) diff --git a/activestorage/CHANGELOG.md b/activestorage/CHANGELOG.md index 323258c77bee9..3145ca4feaa97 100644 --- a/activestorage/CHANGELOG.md +++ b/activestorage/CHANGELOG.md @@ -75,13 +75,6 @@ *Sean Doyle* -* Add support for alternative MD5 implementation through `config.active_storage.checksum_implementation`. - - Also automatically degrade to using the slower `Digest::MD5` implementation if `OpenSSL::Digest::MD5` - is found to be disabled because of OpenSSL FIPS mode. - - *Matt Pasquini*, *Jean Boussier* - * A Blob will no longer autosave associated Attachment. This fixes an issue where a record with an attachment would have diff --git a/activestorage/app/models/active_storage/blob.rb b/activestorage/app/models/active_storage/blob.rb index 202e710496a8f..1e85ee3d36002 100644 --- a/activestorage/app/models/active_storage/blob.rb +++ b/activestorage/app/models/active_storage/blob.rb @@ -332,7 +332,7 @@ def service def compute_checksum_in_chunks(io) raise ArgumentError, "io must be rewindable" unless io.respond_to?(:rewind) - ActiveStorage.checksum_implementation.new.tap do |checksum| + OpenSSL::Digest::MD5.new.tap do |checksum| read_buffer = "".b while io.read(5.megabytes, read_buffer) checksum << read_buffer diff --git a/activestorage/lib/active_storage.rb b/activestorage/lib/active_storage.rb index ea8ea37ec7cdf..df8b398f34686 100644 --- a/activestorage/lib/active_storage.rb +++ b/activestorage/lib/active_storage.rb @@ -362,15 +362,6 @@ module ActiveStorage mattr_accessor :track_variants, default: false - singleton_class.attr_accessor :checksum_implementation - @checksum_implementation = OpenSSL::Digest::MD5 - begin - @checksum_implementation.hexdigest("test") - rescue # OpenSSL may have MD5 disabled - require "digest/md5" - @checksum_implementation = Digest::MD5 - end - mattr_accessor :video_preview_arguments, default: "-y -vframes 1 -f image2" module Transformers diff --git a/activestorage/lib/active_storage/downloader.rb b/activestorage/lib/active_storage/downloader.rb index 29a5e2433915c..319c39f94410a 100644 --- a/activestorage/lib/active_storage/downloader.rb +++ b/activestorage/lib/active_storage/downloader.rb @@ -35,7 +35,7 @@ def download(key, file) end def verify_integrity_of(file, checksum:) - unless ActiveStorage.checksum_implementation.file(file).base64digest == checksum + unless OpenSSL::Digest::MD5.file(file).base64digest == checksum raise ActiveStorage::IntegrityError end end diff --git a/activestorage/lib/active_storage/engine.rb b/activestorage/lib/active_storage/engine.rb index 53a44cd447136..dafc598454a1b 100644 --- a/activestorage/lib/active_storage/engine.rb +++ b/activestorage/lib/active_storage/engine.rb @@ -149,9 +149,6 @@ class Engine < Rails::Engine # :nodoc: ActiveStorage.binary_content_type = app.config.active_storage.binary_content_type || "application/octet-stream" ActiveStorage.video_preview_arguments = app.config.active_storage.video_preview_arguments || "-y -vframes 1 -f image2" ActiveStorage.track_variants = app.config.active_storage.track_variants || false - if app.config.active_storage.checksum_implementation - ActiveStorage.checksum_implementation = app.config.active_storage.checksum_implementation - end end end diff --git a/activestorage/lib/active_storage/service/disk_service.rb b/activestorage/lib/active_storage/service/disk_service.rb index 3f5a323855a8a..ee49bf6b6c59f 100644 --- a/activestorage/lib/active_storage/service/disk_service.rb +++ b/activestorage/lib/active_storage/service/disk_service.rb @@ -161,7 +161,7 @@ def make_path_for(key) end def ensure_integrity_of(key, checksum) - unless ActiveStorage.checksum_implementation.file(path_for(key)).base64digest == checksum + unless OpenSSL::Digest::MD5.file(path_for(key)).base64digest == checksum delete key raise ActiveStorage::IntegrityError end diff --git a/activestorage/test/controllers/direct_uploads_controller_test.rb b/activestorage/test/controllers/direct_uploads_controller_test.rb index 8cb01ed1049ff..d8422c409d962 100644 --- a/activestorage/test/controllers/direct_uploads_controller_test.rb +++ b/activestorage/test/controllers/direct_uploads_controller_test.rb @@ -15,7 +15,7 @@ class ActiveStorage::S3DirectUploadsControllerTest < ActionDispatch::Integration end test "creating new direct upload" do - checksum = ActiveStorage.checksum_implementation.base64digest("Hello") + checksum = OpenSSL::Digest::MD5.base64digest("Hello") metadata = { "foo" => "bar", "my_key_1" => "my_value_1", @@ -61,7 +61,7 @@ class ActiveStorage::GCSDirectUploadsControllerTest < ActionDispatch::Integratio end test "creating new direct upload" do - checksum = ActiveStorage.checksum_implementation.base64digest("Hello") + checksum = OpenSSL::Digest::MD5.base64digest("Hello") metadata = { "foo" => "bar", "my_key_1" => "my_value_1", @@ -94,7 +94,7 @@ class ActiveStorage::GCSDirectUploadsControllerTest < ActionDispatch::Integratio class ActiveStorage::DiskDirectUploadsControllerTest < ActionDispatch::IntegrationTest test "creating new direct upload" do - checksum = ActiveStorage.checksum_implementation.base64digest("Hello") + checksum = OpenSSL::Digest::MD5.base64digest("Hello") metadata = { "foo" => "bar", "my_key_1" => "my_value_1", @@ -119,7 +119,7 @@ class ActiveStorage::DiskDirectUploadsControllerTest < ActionDispatch::Integrati end test "creating new direct upload does not include root in json" do - checksum = ActiveStorage.checksum_implementation.base64digest("Hello") + checksum = OpenSSL::Digest::MD5.base64digest("Hello") metadata = { "foo" => "bar", "my_key_1" => "my_value_1", diff --git a/activestorage/test/controllers/disk_controller_test.rb b/activestorage/test/controllers/disk_controller_test.rb index cb261844457b1..9bc4a19adf76c 100644 --- a/activestorage/test/controllers/disk_controller_test.rb +++ b/activestorage/test/controllers/disk_controller_test.rb @@ -74,7 +74,7 @@ class ActiveStorage::DiskControllerTest < ActionDispatch::IntegrationTest test "directly uploading blob with integrity" do data = "Something else entirely!" - blob = create_blob_before_direct_upload byte_size: data.size, checksum: ActiveStorage.checksum_implementation.base64digest(data) + blob = create_blob_before_direct_upload byte_size: data.size, checksum: OpenSSL::Digest::MD5.base64digest(data) put blob.service_url_for_direct_upload, params: data, headers: { "Content-Type" => "text/plain" } assert_response :no_content @@ -83,7 +83,7 @@ class ActiveStorage::DiskControllerTest < ActionDispatch::IntegrationTest test "directly uploading blob without integrity" do data = "Something else entirely!" - blob = create_blob_before_direct_upload byte_size: data.size, checksum: ActiveStorage.checksum_implementation.base64digest("bad data") + blob = create_blob_before_direct_upload byte_size: data.size, checksum: OpenSSL::Digest::MD5.base64digest("bad data") put blob.service_url_for_direct_upload, params: data assert_response ActionDispatch::Constants::UNPROCESSABLE_CONTENT @@ -92,7 +92,7 @@ class ActiveStorage::DiskControllerTest < ActionDispatch::IntegrationTest test "directly uploading blob with mismatched content type" do data = "Something else entirely!" - blob = create_blob_before_direct_upload byte_size: data.size, checksum: ActiveStorage.checksum_implementation.base64digest(data) + blob = create_blob_before_direct_upload byte_size: data.size, checksum: OpenSSL::Digest::MD5.base64digest(data) put blob.service_url_for_direct_upload, params: data, headers: { "Content-Type" => "application/octet-stream" } assert_response ActionDispatch::Constants::UNPROCESSABLE_CONTENT @@ -102,7 +102,7 @@ class ActiveStorage::DiskControllerTest < ActionDispatch::IntegrationTest test "directly uploading blob with different but equivalent content type" do data = "Something else entirely!" blob = create_blob_before_direct_upload( - byte_size: data.size, checksum: ActiveStorage.checksum_implementation.base64digest(data), content_type: "application/x-gzip") + byte_size: data.size, checksum: OpenSSL::Digest::MD5.base64digest(data), content_type: "application/x-gzip") put blob.service_url_for_direct_upload, params: data, headers: { "Content-Type" => "application/x-gzip" } assert_response :no_content @@ -111,7 +111,7 @@ class ActiveStorage::DiskControllerTest < ActionDispatch::IntegrationTest test "directly uploading blob with mismatched content length" do data = "Something else entirely!" - blob = create_blob_before_direct_upload byte_size: data.size - 1, checksum: ActiveStorage.checksum_implementation.base64digest(data) + blob = create_blob_before_direct_upload byte_size: data.size - 1, checksum: OpenSSL::Digest::MD5.base64digest(data) put blob.service_url_for_direct_upload, params: data, headers: { "Content-Type" => "text/plain" } assert_response ActionDispatch::Constants::UNPROCESSABLE_CONTENT diff --git a/activestorage/test/models/attachment_test.rb b/activestorage/test/models/attachment_test.rb index a39615c35387d..1e1203fb60ef6 100644 --- a/activestorage/test/models/attachment_test.rb +++ b/activestorage/test/models/attachment_test.rb @@ -41,7 +41,7 @@ class ActiveStorage::AttachmentTest < ActiveSupport::TestCase test "attaching a blob doesn't touch the record" do data = "Something else entirely!" io = StringIO.new(data) - blob = create_blob_before_direct_upload byte_size: data.size, checksum: ActiveStorage.checksum_implementation.base64digest(data) + blob = create_blob_before_direct_upload byte_size: data.size, checksum: OpenSSL::Digest::MD5.base64digest(data) blob.upload(io) user = User.create!( diff --git a/activestorage/test/models/blob_test.rb b/activestorage/test/models/blob_test.rb index e28f9957460ed..29003a70fd958 100644 --- a/activestorage/test/models/blob_test.rb +++ b/activestorage/test/models/blob_test.rb @@ -38,7 +38,7 @@ class ActiveStorage::BlobTest < ActiveSupport::TestCase assert_equal data, blob.download assert_equal data.length, blob.byte_size - assert_equal ActiveStorage.checksum_implementation.base64digest(data), blob.checksum + assert_equal OpenSSL::Digest::MD5.base64digest(data), blob.checksum end test "create_and_upload extracts content type from data" do @@ -188,7 +188,7 @@ class ActiveStorage::BlobTest < ActiveSupport::TestCase test "open without integrity" do create_blob(data: "Hello, world!").tap do |blob| - blob.update! checksum: ActiveStorage.checksum_implementation.base64digest("Goodbye, world!") + blob.update! checksum: OpenSSL::Digest::MD5.base64digest("Goodbye, world!") assert_raises ActiveStorage::IntegrityError do blob.open { |file| flunk "Expected integrity check to fail" } diff --git a/activestorage/test/service/gcs_public_service_test.rb b/activestorage/test/service/gcs_public_service_test.rb index 683d6511f3b9c..ed6af9ccbcab5 100644 --- a/activestorage/test/service/gcs_public_service_test.rb +++ b/activestorage/test/service/gcs_public_service_test.rb @@ -21,7 +21,7 @@ class ActiveStorage::Service::GCSPublicServiceTest < ActiveSupport::TestCase test "direct upload" do key = SecureRandom.base58(24) data = "Something else entirely!" - checksum = ActiveStorage.checksum_implementation.base64digest(data) + checksum = OpenSSL::Digest::MD5.base64digest(data) url = @service.url_for_direct_upload(key, expires_in: 5.minutes, content_type: "text/plain", content_length: data.size, checksum: checksum) uri = URI.parse url diff --git a/activestorage/test/service/gcs_service_test.rb b/activestorage/test/service/gcs_service_test.rb index 006bbd30a946c..93a2bb69a1dd2 100644 --- a/activestorage/test/service/gcs_service_test.rb +++ b/activestorage/test/service/gcs_service_test.rb @@ -16,7 +16,7 @@ class ActiveStorage::Service::GCSServiceTest < ActiveSupport::TestCase test "direct upload" do key = SecureRandom.base58(24) data = "Something else entirely!" - checksum = ActiveStorage.checksum_implementation.base64digest(data) + checksum = OpenSSL::Digest::MD5.base64digest(data) url = @service.url_for_direct_upload(key, expires_in: 5.minutes, content_type: "text/plain", content_length: data.size, checksum: checksum) uri = URI.parse url @@ -36,7 +36,7 @@ class ActiveStorage::Service::GCSServiceTest < ActiveSupport::TestCase test "direct upload with content disposition" do key = SecureRandom.base58(24) data = "Something else entirely!" - checksum = ActiveStorage.checksum_implementation.base64digest(data) + checksum = OpenSSL::Digest::MD5.base64digest(data) url = @service.url_for_direct_upload(key, expires_in: 5.minutes, content_type: "text/plain", content_length: data.size, checksum: checksum) uri = URI.parse url @@ -91,7 +91,7 @@ class ActiveStorage::Service::GCSServiceTest < ActiveSupport::TestCase key = SecureRandom.base58(24) data = "Something else entirely!" - @service.upload(key, StringIO.new(data), checksum: ActiveStorage.checksum_implementation.base64digest(data), disposition: :attachment, filename: ActiveStorage::Filename.new("test.txt"), content_type: "text/plain") + @service.upload(key, StringIO.new(data), checksum: OpenSSL::Digest::MD5.base64digest(data), disposition: :attachment, filename: ActiveStorage::Filename.new("test.txt"), content_type: "text/plain") url = @service.url(key, expires_in: 2.minutes, disposition: :inline, content_type: "text/html", filename: ActiveStorage::Filename.new("test.html")) response = Net::HTTP.get_response(URI(url)) @@ -105,7 +105,7 @@ class ActiveStorage::Service::GCSServiceTest < ActiveSupport::TestCase key = SecureRandom.base58(24) data = "Something else entirely!" - @service.upload(key, StringIO.new(data), checksum: ActiveStorage.checksum_implementation.base64digest(data), content_type: "text/plain") + @service.upload(key, StringIO.new(data), checksum: OpenSSL::Digest::MD5.base64digest(data), content_type: "text/plain") url = @service.url(key, expires_in: 2.minutes, disposition: :inline, content_type: "text/html", filename: ActiveStorage::Filename.new("test.html")) response = Net::HTTP.get_response(URI(url)) @@ -148,7 +148,7 @@ class ActiveStorage::Service::GCSServiceTest < ActiveSupport::TestCase test "update custom_metadata" do key = SecureRandom.base58(24) data = "Something else entirely!" - @service.upload(key, StringIO.new(data), checksum: ActiveStorage.checksum_implementation.base64digest(data), disposition: :attachment, filename: ActiveStorage::Filename.new("test.html"), content_type: "text/html", custom_metadata: { "foo" => "baz" }) + @service.upload(key, StringIO.new(data), checksum: OpenSSL::Digest::MD5.base64digest(data), disposition: :attachment, filename: ActiveStorage::Filename.new("test.html"), content_type: "text/html", custom_metadata: { "foo" => "baz" }) @service.update_metadata(key, disposition: :inline, filename: ActiveStorage::Filename.new("test.txt"), content_type: "text/plain", custom_metadata: { "foo" => "bar" }) url = @service.url(key, expires_in: 2.minutes, disposition: :attachment, content_type: "text/html", filename: ActiveStorage::Filename.new("test.html")) diff --git a/activestorage/test/service/mirror_service_test.rb b/activestorage/test/service/mirror_service_test.rb index 58e58092a3ee8..dc2ebc0eeeb96 100644 --- a/activestorage/test/service/mirror_service_test.rb +++ b/activestorage/test/service/mirror_service_test.rb @@ -29,7 +29,7 @@ class ActiveStorage::Service::MirrorServiceTest < ActiveSupport::TestCase key = SecureRandom.base58(24) data = "Something else entirely!" io = StringIO.new(data) - checksum = ActiveStorage.checksum_implementation.base64digest(data) + checksum = OpenSSL::Digest::MD5.base64digest(data) assert_performed_jobs 1, only: ActiveStorage::MirrorJob do @service.upload key, io.tap(&:read), checksum: checksum @@ -49,7 +49,7 @@ class ActiveStorage::Service::MirrorServiceTest < ActiveSupport::TestCase test "downloading from primary service" do key = SecureRandom.base58(24) data = "Something else entirely!" - checksum = ActiveStorage.checksum_implementation.base64digest(data) + checksum = OpenSSL::Digest::MD5.base64digest(data) @service.primary.upload key, StringIO.new(data), checksum: checksum @@ -68,7 +68,7 @@ class ActiveStorage::Service::MirrorServiceTest < ActiveSupport::TestCase test "mirroring a file from the primary service to secondary services where it doesn't exist" do key = SecureRandom.base58(24) data = "Something else entirely!" - checksum = ActiveStorage.checksum_implementation.base64digest(data) + checksum = OpenSSL::Digest::MD5.base64digest(data) @service.primary.upload key, StringIO.new(data), checksum: checksum @service.mirrors.third.upload key, StringIO.new("Surprise!") diff --git a/activestorage/test/service/s3_public_service_test.rb b/activestorage/test/service/s3_public_service_test.rb index 062d31444d1be..8f671bb3cd4b9 100644 --- a/activestorage/test/service/s3_public_service_test.rb +++ b/activestorage/test/service/s3_public_service_test.rb @@ -35,7 +35,7 @@ class ActiveStorage::Service::S3PublicServiceTest < ActiveSupport::TestCase test "direct upload" do key = SecureRandom.base58(24) data = "Something else entirely!" - checksum = ActiveStorage.checksum_implementation.base64digest(data) + checksum = OpenSSL::Digest::MD5.base64digest(data) url = @service.url_for_direct_upload(key, expires_in: 5.minutes, content_type: "text/plain", content_length: data.size, checksum: checksum) uri = URI.parse url diff --git a/activestorage/test/service/s3_service_test.rb b/activestorage/test/service/s3_service_test.rb index ef80a7d11a933..11ca847b52dce 100644 --- a/activestorage/test/service/s3_service_test.rb +++ b/activestorage/test/service/s3_service_test.rb @@ -17,7 +17,7 @@ class ActiveStorage::Service::S3ServiceTest < ActiveSupport::TestCase test "direct upload" do key = SecureRandom.base58(24) data = "Something else entirely!" - checksum = ActiveStorage.checksum_implementation.base64digest(data) + checksum = OpenSSL::Digest::MD5.base64digest(data) url = @service.url_for_direct_upload(key, expires_in: 5.minutes, content_type: "text/plain", content_length: data.size, checksum: checksum) uri = URI.parse url @@ -37,7 +37,7 @@ class ActiveStorage::Service::S3ServiceTest < ActiveSupport::TestCase test "direct upload with content disposition" do key = SecureRandom.base58(24) data = "Something else entirely!" - checksum = ActiveStorage.checksum_implementation.base64digest(data) + checksum = OpenSSL::Digest::MD5.base64digest(data) url = @service.url_for_direct_upload(key, expires_in: 5.minutes, content_type: "text/plain", content_length: data.size, checksum: checksum) uri = URI.parse url @@ -58,7 +58,7 @@ class ActiveStorage::Service::S3ServiceTest < ActiveSupport::TestCase test "directly uploading file larger than the provided content-length does not work" do key = SecureRandom.base58(24) data = "Some text that is longer than the specified content length" - checksum = ActiveStorage.checksum_implementation.base64digest(data) + checksum = OpenSSL::Digest::MD5.base64digest(data) url = @service.url_for_direct_upload(key, expires_in: 5.minutes, content_type: "text/plain", content_length: data.size - 1, checksum: checksum) uri = URI.parse url @@ -99,7 +99,7 @@ class ActiveStorage::Service::S3ServiceTest < ActiveSupport::TestCase begin key = SecureRandom.base58(24) data = "Something else entirely!" - service.upload key, StringIO.new(data), checksum: ActiveStorage.checksum_implementation.base64digest(data) + service.upload key, StringIO.new(data), checksum: OpenSSL::Digest::MD5.base64digest(data) assert_equal "AES256", service.bucket.object(key).server_side_encryption ensure @@ -115,7 +115,7 @@ class ActiveStorage::Service::S3ServiceTest < ActiveSupport::TestCase @service.upload( key, StringIO.new(data), - checksum: ActiveStorage.checksum_implementation.base64digest(data), + checksum: OpenSSL::Digest::MD5.base64digest(data), filename: "cool_data.txt", content_type: content_type ) @@ -152,7 +152,7 @@ class ActiveStorage::Service::S3ServiceTest < ActiveSupport::TestCase @service.upload( key, StringIO.new(data), - checksum: ActiveStorage.checksum_implementation.base64digest(data), + checksum: OpenSSL::Digest::MD5.base64digest(data), filename: ActiveStorage::Filename.new("cool_data.txt"), disposition: :attachment ) @@ -169,7 +169,7 @@ class ActiveStorage::Service::S3ServiceTest < ActiveSupport::TestCase key = SecureRandom.base58(24) data = SecureRandom.bytes(8.megabytes) - service.upload key, StringIO.new(data), checksum: ActiveStorage.checksum_implementation.base64digest(data) + service.upload key, StringIO.new(data), checksum: OpenSSL::Digest::MD5.base64digest(data) assert data == service.download(key) ensure service.delete key @@ -183,7 +183,7 @@ class ActiveStorage::Service::S3ServiceTest < ActiveSupport::TestCase key = SecureRandom.base58(24) data = SecureRandom.bytes(3.megabytes) - service.upload key, StringIO.new(data), checksum: ActiveStorage.checksum_implementation.base64digest(data) + service.upload key, StringIO.new(data), checksum: OpenSSL::Digest::MD5.base64digest(data) assert data == service.download(key) ensure service.delete key diff --git a/activestorage/test/service/shared_service_tests.rb b/activestorage/test/service/shared_service_tests.rb index 9900bd990e2a7..4695af821a927 100644 --- a/activestorage/test/service/shared_service_tests.rb +++ b/activestorage/test/service/shared_service_tests.rb @@ -22,7 +22,7 @@ module ActiveStorage::Service::SharedServiceTests test "uploading with integrity" do key = SecureRandom.base58(24) data = "Something else entirely!" - @service.upload(key, StringIO.new(data), checksum: ActiveStorage.checksum_implementation.base64digest(data)) + @service.upload(key, StringIO.new(data), checksum: OpenSSL::Digest::MD5.base64digest(data)) assert_equal data, @service.download(key) ensure @@ -34,7 +34,7 @@ module ActiveStorage::Service::SharedServiceTests data = "Something else entirely!" assert_raises(ActiveStorage::IntegrityError) do - @service.upload(key, StringIO.new(data), checksum: ActiveStorage.checksum_implementation.base64digest("bad data")) + @service.upload(key, StringIO.new(data), checksum: OpenSSL::Digest::MD5.base64digest("bad data")) end assert_not @service.exist?(key) @@ -48,7 +48,7 @@ module ActiveStorage::Service::SharedServiceTests @service.upload( key, StringIO.new(data), - checksum: ActiveStorage.checksum_implementation.base64digest(data), + checksum: OpenSSL::Digest::MD5.base64digest(data), filename: "racecar.jpg", content_type: "image/jpeg" ) diff --git a/activestorage/test/test_helper.rb b/activestorage/test/test_helper.rb index 12dae0da20609..14d7365bb44f6 100644 --- a/activestorage/test/test_helper.rb +++ b/activestorage/test/test_helper.rb @@ -56,7 +56,7 @@ def build_blob_after_unfurling(key: nil, data: "Hello world!", filename: "hello. def directly_upload_file_blob(filename: "racecar.jpg", content_type: "image/jpeg", record: nil) file = file_fixture(filename) byte_size = file.size - checksum = ActiveStorage.checksum_implementation.file(file).base64digest + checksum = OpenSSL::Digest::MD5.file(file).base64digest create_blob_before_direct_upload(filename: filename, byte_size: byte_size, checksum: checksum, content_type: content_type, record: record).tap do |blob| service = ActiveStorage::Blob.service.try(:primary) || ActiveStorage::Blob.service diff --git a/guides/source/configuring.md b/guides/source/configuring.md index 7b92f6206ee44..73d23ad926546 100644 --- a/guides/source/configuring.md +++ b/guides/source/configuring.md @@ -3153,15 +3153,6 @@ The default value is `/https?:\/\/localhost:\d+/` in the `development` environme `config.active_storage` provides the following configuration options: -#### `config.active_storage.checksum_implementation` - -Specify which digest implementation to use for internal checksums. -The value must respond to Ruby's `Digest` interface. - -| Starting with version | The default value is | -| --------------------- | --------------------------------------- | -| (original) | `OpenSSL::Digest::MD5` or `Digest::MD5` | - #### `config.active_storage.variant_processor` Accepts a symbol `:mini_magick`, `:vips`, or `:disabled` specifying whether or not variant transformations and blob analysis will be performed with MiniMagick or ruby-vips. From 8c624be05a5660772cbb6305862c3921b2f5788f Mon Sep 17 00:00:00 2001 From: Sean Doyle Date: Fri, 10 Oct 2025 09:23:46 +0200 Subject: [PATCH 0771/1075] De-couple `@rails/actiontext/attachment_upload.js` from `Trix.Attachment` `AttachmentUpload` property access --- First, change the `AttachmentUpload` implementation to be less coupled to Trix and [Trix.Attachment][] instances. Add an optional `file` argument to its constructor. When the `file` argument **is not** specified, it will be set to a default value of `attachment.file`. In that case, the constructor assumes that `attachment` is an instance of `Trix.Attachment`, and will read a [File][] instance from its `.file` property. The `attachment` argument is retained and set to an `.attachment` property. Subsequent `direct-upload:`-prefixed events will continue to be dispatched with the value of the `attachment` argument assigned to the event's `detail.attachment` property. **This aims to maintain backwards compatibility**. When the `file` argument **is** specified and set to a [File][] instance, it will be used throughout the rest of the implementation. This enables future Action Text editors that are not Trix to provide `File` instances directly in case their variety of "attachment" does not have `.file` property. Replace `AttachmentUpload` calls with events --- Next, remove any calls to `Trix.Attachment.setUploadProgress` and replace them with code in the `@rails/actiontext/index.js` module that listens for corresponding `direct-upload:progress` event listeners. Make `AttachmentUpload.start()` return a [Promise][] --- Maintain the behavior that dispatches `direct-upload:error` and `direct-upload:end` events. In addition to those hooks, editors can also wait for the resolution of the [Promise][] instance returned by `AttachmentUpload.start()`. The resolved value will include the `sgid` and `url` attributes. Remove calls to `Trix.Attachment.setAttributes`, and instead move the assignment of those attributes to the `@rails/actiontext/index.js` module's event listeners. [Promise]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise [File]: https://developer.mozilla.org/en-US/docs/Web/API/File [Trix.Attachment]: https://github.com/basecamp/trix/blob/v2.1.15/src/trix/models/attachment.js --- actiontext/CHANGELOG.md | 7 +++ .../app/assets/javascripts/actiontext.esm.js | 34 ++++++++------- .../app/assets/javascripts/actiontext.js | 43 +++++++++++-------- .../actiontext/attachment_upload.js | 26 +++++------ actiontext/app/javascript/actiontext/index.js | 11 ++++- 5 files changed, 74 insertions(+), 47 deletions(-) diff --git a/actiontext/CHANGELOG.md b/actiontext/CHANGELOG.md index 1cbdcca25e5a2..31cd7aa12b4f1 100644 --- a/actiontext/CHANGELOG.md +++ b/actiontext/CHANGELOG.md @@ -1,3 +1,10 @@ +* De-couple `@rails/actiontext/attachment_upload.js` from `Trix.Attachment` + + Implement `@rails/actiontext/index.js` with a `direct-upload:progress` event + listeners and `Promise` resolution. + + *Sean Doyle* + * Capture block content for form helper methods ```erb diff --git a/actiontext/app/assets/javascripts/actiontext.esm.js b/actiontext/app/assets/javascripts/actiontext.esm.js index 6ee8fd8eda73b..281013756ceaf 100644 --- a/actiontext/app/assets/javascripts/actiontext.esm.js +++ b/actiontext/app/assets/javascripts/actiontext.esm.js @@ -882,19 +882,21 @@ function autostart() { setTimeout(autostart, 1); class AttachmentUpload { - constructor(attachment, element) { + constructor(attachment, element, file = attachment.file) { this.attachment = attachment; this.element = element; - this.directUpload = new DirectUpload(attachment.file, this.directUploadUrl, this); + this.directUpload = new DirectUpload(file, this.directUploadUrl, this); + this.file = file; } start() { - this.directUpload.create(this.directUploadDidComplete.bind(this)); - this.dispatch("start"); + return new Promise(((resolve, reject) => { + this.directUpload.create(((error, attributes) => this.directUploadDidComplete(error, attributes, resolve, reject))); + this.dispatch("start"); + })); } directUploadWillStoreFileWithXHR(xhr) { xhr.upload.addEventListener("progress", (event => { const progress = event.loaded / event.total * 90; - this.attachment.setUploadProgress(progress); if (progress) { this.dispatch("progress", { progress: progress @@ -913,7 +915,6 @@ class AttachmentUpload { const estimatedResponseTime = this.estimateResponseTime(); const responseProgress = Math.min(elapsed / estimatedResponseTime, 1); progress = 90 + responseProgress * 9; - this.attachment.setUploadProgress(progress); this.dispatch("progress", { progress: progress }); @@ -922,7 +923,6 @@ class AttachmentUpload { } }; xhr.addEventListener("loadend", (() => { - this.attachment.setUploadProgress(100); this.dispatch("progress", { progress: 100 }); @@ -930,7 +930,7 @@ class AttachmentUpload { requestAnimationFrame(updateProgress); } estimateResponseTime() { - const fileSize = this.attachment.file.size; + const fileSize = this.file.size; const MB = 1024 * 1024; if (fileSize < MB) { return 1e3; @@ -940,11 +940,11 @@ class AttachmentUpload { return 3e3 + fileSize / MB * 50; } } - directUploadDidComplete(error, attributes) { + directUploadDidComplete(error, attributes, resolve, reject) { if (error) { - this.dispatchError(error); + this.dispatchError(error, reject); } else { - this.attachment.setAttributes({ + resolve({ sgid: attributes.attachable_sgid, url: this.createBlobUrl(attributes.signed_id, attributes.filename) }); @@ -960,12 +960,12 @@ class AttachmentUpload { detail: detail }); } - dispatchError(error) { + dispatchError(error, reject) { const event = this.dispatch("error", { error: error }); if (!event.defaultPrevented) { - alert(error); + reject(error); } } get directUploadUrl() { @@ -979,7 +979,11 @@ class AttachmentUpload { addEventListener("trix-attachment-add", (event => { const {attachment: attachment, target: target} = event; if (attachment.file) { - const upload = new AttachmentUpload(attachment, target); - upload.start(); + const upload = new AttachmentUpload(attachment, target, attachment.file); + const onProgress = event => attachment.setUploadProgress(event.detail.progress); + target.addEventListener("direct-upload:progress", onProgress); + upload.start().then((attributes => attachment.setAttributes(attributes))).catch((error => alert(error))).finally((() => target.removeEventListener("direct-upload:progress", onProgress))); } })); + +export { AttachmentUpload }; diff --git a/actiontext/app/assets/javascripts/actiontext.js b/actiontext/app/assets/javascripts/actiontext.js index 46b8a873c72ff..8de57d54fe345 100644 --- a/actiontext/app/assets/javascripts/actiontext.js +++ b/actiontext/app/assets/javascripts/actiontext.js @@ -1,6 +1,7 @@ -(function(factory) { - typeof define === "function" && define.amd ? define(factory) : factory(); -})((function() { +(function(global, factory) { + typeof exports === "object" && typeof module !== "undefined" ? factory(exports) : typeof define === "function" && define.amd ? define([ "exports" ], factory) : (global = typeof globalThis !== "undefined" ? globalThis : global || self, + factory(global.ActionText = {})); +})(this, (function(exports) { "use strict"; var sparkMd5 = { exports: {} @@ -855,19 +856,21 @@ } setTimeout(autostart, 1); class AttachmentUpload { - constructor(attachment, element) { + constructor(attachment, element, file = attachment.file) { this.attachment = attachment; this.element = element; - this.directUpload = new DirectUpload(attachment.file, this.directUploadUrl, this); + this.directUpload = new DirectUpload(file, this.directUploadUrl, this); + this.file = file; } start() { - this.directUpload.create(this.directUploadDidComplete.bind(this)); - this.dispatch("start"); + return new Promise(((resolve, reject) => { + this.directUpload.create(((error, attributes) => this.directUploadDidComplete(error, attributes, resolve, reject))); + this.dispatch("start"); + })); } directUploadWillStoreFileWithXHR(xhr) { xhr.upload.addEventListener("progress", (event => { const progress = event.loaded / event.total * 90; - this.attachment.setUploadProgress(progress); if (progress) { this.dispatch("progress", { progress: progress @@ -886,7 +889,6 @@ const estimatedResponseTime = this.estimateResponseTime(); const responseProgress = Math.min(elapsed / estimatedResponseTime, 1); progress = 90 + responseProgress * 9; - this.attachment.setUploadProgress(progress); this.dispatch("progress", { progress: progress }); @@ -895,7 +897,6 @@ } }; xhr.addEventListener("loadend", (() => { - this.attachment.setUploadProgress(100); this.dispatch("progress", { progress: 100 }); @@ -903,7 +904,7 @@ requestAnimationFrame(updateProgress); } estimateResponseTime() { - const fileSize = this.attachment.file.size; + const fileSize = this.file.size; const MB = 1024 * 1024; if (fileSize < MB) { return 1e3; @@ -913,11 +914,11 @@ return 3e3 + fileSize / MB * 50; } } - directUploadDidComplete(error, attributes) { + directUploadDidComplete(error, attributes, resolve, reject) { if (error) { - this.dispatchError(error); + this.dispatchError(error, reject); } else { - this.attachment.setAttributes({ + resolve({ sgid: attributes.attachable_sgid, url: this.createBlobUrl(attributes.signed_id, attributes.filename) }); @@ -933,12 +934,12 @@ detail: detail }); } - dispatchError(error) { + dispatchError(error, reject) { const event = this.dispatch("error", { error: error }); if (!event.defaultPrevented) { - alert(error); + reject(error); } } get directUploadUrl() { @@ -951,8 +952,14 @@ addEventListener("trix-attachment-add", (event => { const {attachment: attachment, target: target} = event; if (attachment.file) { - const upload = new AttachmentUpload(attachment, target); - upload.start(); + const upload = new AttachmentUpload(attachment, target, attachment.file); + const onProgress = event => attachment.setUploadProgress(event.detail.progress); + target.addEventListener("direct-upload:progress", onProgress); + upload.start().then((attributes => attachment.setAttributes(attributes))).catch((error => alert(error))).finally((() => target.removeEventListener("direct-upload:progress", onProgress))); } })); + exports.AttachmentUpload = AttachmentUpload; + Object.defineProperty(exports, "__esModule", { + value: true + }); })); diff --git a/actiontext/app/javascript/actiontext/attachment_upload.js b/actiontext/app/javascript/actiontext/attachment_upload.js index 354fa35a0dba6..34a8802e4c1f4 100644 --- a/actiontext/app/javascript/actiontext/attachment_upload.js +++ b/actiontext/app/javascript/actiontext/attachment_upload.js @@ -1,22 +1,24 @@ import { DirectUpload, dispatchEvent } from "@rails/activestorage" export class AttachmentUpload { - constructor(attachment, element) { + constructor(attachment, element, file = attachment.file) { this.attachment = attachment this.element = element - this.directUpload = new DirectUpload(attachment.file, this.directUploadUrl, this) + this.directUpload = new DirectUpload(file, this.directUploadUrl, this) + this.file = file } start() { - this.directUpload.create(this.directUploadDidComplete.bind(this)) - this.dispatch("start") + return new Promise((resolve, reject) => { + this.directUpload.create((error, attributes) => this.directUploadDidComplete(error, attributes, resolve, reject)) + this.dispatch("start") + }) } directUploadWillStoreFileWithXHR(xhr) { xhr.upload.addEventListener("progress", event => { // Scale upload progress to 0-90% range const progress = (event.loaded / event.total) * 90 - this.attachment.setUploadProgress(progress) if (progress) { this.dispatch("progress", { progress: progress }) } @@ -39,7 +41,6 @@ export class AttachmentUpload { const responseProgress = Math.min(elapsed / estimatedResponseTime, 1) progress = 90 + (responseProgress * 9) // 90% to 99% - this.attachment.setUploadProgress(progress) this.dispatch("progress", { progress }) // Continue until response arrives or we hit 99% @@ -50,7 +51,6 @@ export class AttachmentUpload { // Stop simulation when response arrives xhr.addEventListener("loadend", () => { - this.attachment.setUploadProgress(100) this.dispatch("progress", { progress: 100 }) }) @@ -59,7 +59,7 @@ export class AttachmentUpload { estimateResponseTime() { // Base estimate: 1 second for small files, scaling up for larger files - const fileSize = this.attachment.file.size + const fileSize = this.file.size const MB = 1024 * 1024 if (fileSize < MB) { @@ -71,11 +71,11 @@ export class AttachmentUpload { } } - directUploadDidComplete(error, attributes) { + directUploadDidComplete(error, attributes, resolve, reject) { if (error) { - this.dispatchError(error) + this.dispatchError(error, reject) } else { - this.attachment.setAttributes({ + resolve({ sgid: attributes.attachable_sgid, url: this.createBlobUrl(attributes.signed_id, attributes.filename) }) @@ -94,10 +94,10 @@ export class AttachmentUpload { return dispatchEvent(this.element, `direct-upload:${name}`, { detail }) } - dispatchError(error) { + dispatchError(error, reject) { const event = this.dispatch("error", { error }) if (!event.defaultPrevented) { - alert(error); + reject(error) } } diff --git a/actiontext/app/javascript/actiontext/index.js b/actiontext/app/javascript/actiontext/index.js index 0e9251018ae16..86a57189f0880 100644 --- a/actiontext/app/javascript/actiontext/index.js +++ b/actiontext/app/javascript/actiontext/index.js @@ -4,7 +4,16 @@ addEventListener("trix-attachment-add", event => { const { attachment, target } = event if (attachment.file) { - const upload = new AttachmentUpload(attachment, target) + const upload = new AttachmentUpload(attachment, target, attachment.file) + const onProgress = event => attachment.setUploadProgress(event.detail.progress) + + target.addEventListener("direct-upload:progress", onProgress) + upload.start() + .then(attributes => attachment.setAttributes(attributes)) + .catch(error => alert(error)) + .finally(() => target.removeEventListener("direct-upload:progress", onProgress)) } }) + +export { AttachmentUpload } From aec9c73bbff023011877dddd844226c1fc885154 Mon Sep 17 00:00:00 2001 From: hachi8833 Date: Mon, 13 Oct 2025 20:43:27 +0900 Subject: [PATCH 0772/1075] [ci-skip][doc] Update active_storage_overview.md --- guides/source/active_storage_overview.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/guides/source/active_storage_overview.md b/guides/source/active_storage_overview.md index 65566bc53aefe..6e6fa349da840 100644 --- a/guides/source/active_storage_overview.md +++ b/guides/source/active_storage_overview.md @@ -1006,15 +1006,18 @@ directly from the client to the cloud. ### Usage -1. Include the Active Storage JavaScript in your application's JavaScript bundle or reference it directly. +1. Include the Active Storage JavaScript in your application's JavaScript +bundle or reference it directly. - Requiring directly without bundling through the asset pipeline in the application HTML with autostart: + Requiring it directly in the application HTML with autostart, instead of + bundling it through the asset pipeline: ```erb <%= javascript_include_tag "activestorage" %> ``` - Requiring via importmap-rails without bundling through the asset pipeline in the application HTML without autostart as ESM: + Requiring via importmap-rails as an ESM in the application HTML, instead of + bundling it through the asset pipeline and using autostart: ```ruby # config/importmap.rb @@ -1041,7 +1044,9 @@ directly from the client to the cloud. ActiveStorage.start() ``` -2. Annotate file inputs with the direct upload URL using Rails' [file field helper](form_helpers.html#uploading-files). +2. Add `direct_upload: true` option to your [`file_field` +helper](form_helpers.html#uploading-files) to automatically annotate the +input field with the direct upload URL via `data-direct-upload-url` attribute. ```erb <%= form.file_field :attachments, multiple: true, direct_upload: true %> From 9ca59b0c9468466ad3a4e5472a63f4e99add23a6 Mon Sep 17 00:00:00 2001 From: Ryan Kulp Date: Tue, 14 Oct 2025 20:27:07 -0400 Subject: [PATCH 0773/1075] Pre-fill and persist /rails/info/routes search input --- .../templates/routes/_table.html.erb | 23 +++++++++++++++++- railties/CHANGELOG.md | 4 ++++ railties/lib/rails/info_controller.rb | 24 +++++++++++-------- railties/test/rails_info_controller_test.rb | 14 +++++------ 4 files changed, 47 insertions(+), 18 deletions(-) diff --git a/actionpack/lib/action_dispatch/middleware/templates/routes/_table.html.erb b/actionpack/lib/action_dispatch/middleware/templates/routes/_table.html.erb index 8b6b8df227e39..7e8ffc9760945 100644 --- a/actionpack/lib/action_dispatch/middleware/templates/routes/_table.html.erb +++ b/actionpack/lib/action_dispatch/middleware/templates/routes/_table.html.erb @@ -122,6 +122,7 @@ function getJSON(url, success) { var xhr = new XMLHttpRequest(); xhr.open('GET', url); + xhr.setRequestHeader('accept', 'application/json'); xhr.onload = function() { if (this.status == 200) success(JSON.parse(this.response)); @@ -152,6 +153,7 @@ if (searchElem.value === "") { exactSection.innerHTML = ""; fuzzySection.innerHTML = ""; + updateQueryState(""); } } @@ -164,6 +166,12 @@ return tr; } + function updateQueryState(query) { + var currentUrl = new URL(location); + currentUrl.searchParams.set('query', query) + history.pushState({}, '', currentUrl.toString()); + } + // On key press perform a search for matching paths delayedKeyup(searchElem, function() { var query = sanitizeQuery(searchElem.value), @@ -175,6 +183,7 @@ if (!query) return searchElem.onblur(); + updateQueryState(query); getJSON('/rails/info/routes?query=' + query, function(matches){ // Clear out results section exactSection.replaceChildren(defaultExactMatch); @@ -224,9 +233,21 @@ }); } + // Pre-fills the search input with existing query + function setupPrefilledQuery() { + let urlParams = new URLSearchParams(location.search); + let query = urlParams.get('query'); + + if (query) { + search.value = query; + search.dispatchEvent(new KeyboardEvent('keyup')); + } + } + setupMatchingRoutes(); setupRouteToggleHelperLinks(); + setupPrefilledQuery(); // Focus the search input after page has loaded - document.getElementById('search').focus(); + search.focus(); diff --git a/railties/CHANGELOG.md b/railties/CHANGELOG.md index aab6c99d37b4d..29d346c5f7b3f 100644 --- a/railties/CHANGELOG.md +++ b/railties/CHANGELOG.md @@ -1,3 +1,7 @@ +* Persist `/rails/info/routes` search query and results between page reloads. + + *Ryan Kulp* + * Remove deprecated `STATS_DIRECTORIES`. *Rafael Mendonça França* diff --git a/railties/lib/rails/info_controller.rb b/railties/lib/rails/info_controller.rb index a6151a460a07e..8963073f91e44 100644 --- a/railties/lib/rails/info_controller.rb +++ b/railties/lib/rails/info_controller.rb @@ -19,16 +19,20 @@ def properties end def routes - if query = params[:query] - query = URI::RFC2396_PARSER.escape query - - render json: { - exact: matching_routes(query: query, exact_match: true), - fuzzy: matching_routes(query: query, exact_match: false) - } - else - @routes_inspector = ActionDispatch::Routing::RoutesInspector.new(Rails.application.routes.routes) - @page_title = "Routes" + respond_to do |format| + format.html do + @routes_inspector = ActionDispatch::Routing::RoutesInspector.new(Rails.application.routes.routes) + @page_title = "Routes" + end + + format.json do + query = URI::RFC2396_PARSER.escape params[:query] + + render json: { + exact: matching_routes(query: query, exact_match: true), + fuzzy: matching_routes(query: query, exact_match: false) + } + end end end diff --git a/railties/test/rails_info_controller_test.rb b/railties/test/rails_info_controller_test.rb index 0fb8bac37cc16..61e2194f596e7 100644 --- a/railties/test/rails_info_controller_test.rb +++ b/railties/test/rails_info_controller_test.rb @@ -110,7 +110,7 @@ def fuzzy_results end test "info controller search returns exact matches for route names" do - get :routes, params: { query: "rails_info_" } + get :routes, params: { query: "rails_info_" }, as: :json assert exact_results.size == 0, "should not match incomplete route names" get :routes, params: { query: "" } @@ -134,7 +134,7 @@ def fuzzy_results end test "info controller search returns exact matches for route paths" do - get :routes, params: { query: "rails/info/route" } + get :routes, params: { query: "rails/info/route" }, as: :json assert exact_results.size == 0, "should not match incomplete route paths" get :routes, params: { query: "/rails/info/routes" } @@ -155,7 +155,7 @@ def fuzzy_results end test "info controller search returns case-sensitive exact matches for HTTP Verb methods" do - get :routes, params: { query: "GE" } + get :routes, params: { query: "GE" }, as: :json assert exact_results.size == 0, "should not match incomplete HTTP Verb methods" get :routes, params: { query: "get" } @@ -170,7 +170,7 @@ def fuzzy_results end test "info controller search returns exact matches for route Controller#Action(s)" do - get :routes, params: { query: "rails/info#propertie" } + get :routes, params: { query: "rails/info#propertie" }, as: :json assert exact_results.size == 0, "should not match incomplete route Controller#Action(s)" get :routes, params: { query: "rails/info#properties" } @@ -181,7 +181,7 @@ def fuzzy_results end test "info controller returns fuzzy matches for route names" do - get :routes, params: { query: "" } + get :routes, params: { query: "" }, as: :json assert exact_results.size == 0, "should not match unnamed routes" get :routes, params: { query: "rails_info" } @@ -205,7 +205,7 @@ def fuzzy_results end test "info controller returns fuzzy matches for route paths" do - get :routes, params: { query: "rails/:test" } + get :routes, params: { query: "rails/:test" }, as: :json assert fuzzy_results.size == 2, "should match incomplete routes" assert fuzzy_results.include? "/rails/:test/properties(.:format)" assert fuzzy_results.include? "/rails/:test/named_properties(.:format)" @@ -221,7 +221,7 @@ def fuzzy_results # Intentionally ignoring fuzzy match of HTTP Verb methods. There's not much value to 'GE' returning 'GET' results. test "info controller search returns fuzzy matches for route Controller#Action(s)" do - get :routes, params: { query: "rails/info#propertie" } + get :routes, params: { query: "rails/info#propertie" }, as: :json assert fuzzy_results.size == 3, "should match incomplete routes" assert fuzzy_results.include? "/rails/info/properties(.:format)" assert fuzzy_results.include? "/rails/:test/properties(.:format)" From 2b4c61a1237265511006a8a559dc908bfbb84a41 Mon Sep 17 00:00:00 2001 From: Yasuo Honda Date: Wed, 15 Oct 2025 09:28:47 +0900 Subject: [PATCH 0774/1075] Address test_verbose_redirect_logs failure for Ruby 3.5.0dev MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit addresses the following failure with Ruby 3.5.0.dev since `source_location` includes start_column, end_line, end_column. Follow up #55889 - Fixed by this commit ```ruby $ ruby -v ruby 3.5.0dev (2025-10-14T22:26:08Z master df5d63cfa2) +PRISM [x86_64-linux] $ bin/test test/controller/log_subscriber_test.rb:331 Running 36 tests in a single process (parallelization threshold is 50) Run options: --seed 1476 F Failure: ACLogSubscriberTest#test_verbose_redirect_logs [test/controller/log_subscriber_test.rb:331]: Expected /\u21B3 \/home\/yahonda\/src\/github.com\/rails\/rails\/actionpack\/test\/controller\/log_subscriber_test.rb:8/ to match "↳ /home/yahonda/src/github.com/rails/rails/actionpack/test/controller/log_subscriber_test.rb:28:in 'Another::LogSubscribersController#redirector'". bin/test test/controller/log_subscriber_test.rb:320 Finished in 0.063581s, 15.7279 runs/s, 47.1836 assertions/s. 1 runs, 3 assertions, 1 failures, 0 errors, 0 skips $ ``` - Additional information - Modified to show the entire source_location ``` $ git diff -- test/controller/log_subscriber_test.rb diff --git a/actionpack/test/controller/log_subscriber_test.rb b/actionpack/test/controller/log_subscriber_test.rb index 0b6e5b0443..e19848b769 100644 --- a/actionpack/test/controller/log_subscriber_test.rb +++ b/actionpack/test/controller/log_subscriber_test.rb @@ -318,6 +318,7 @@ def test_filter_redirect_bad_uri end def test_verbose_redirect_logs + pp Another::LogSubscribersController.instance_method(:redirector).source_location line = Another::LogSubscribersController.instance_method(:redirector).source_location.last + 1 old_cleaner = ActionController::LogSubscriber.backtrace_cleaner ActionController::LogSubscriber.backtrace_cleaner = ActionController::LogSubscriber.backtrace_cleaner.dup $ ``` - Ruby 3.5.0dev source_location returns an array of 5 elements. ``` $ ruby -v ruby 3.5.0dev (2025-10-14T22:26:08Z master df5d63cfa2) +PRISM [x86_64-linux] $ bin/test test/controller/log_subscriber_test.rb:331 Running 36 tests in a single process (parallelization threshold is 50) Run options: --seed 37374 ["/home/yahonda/src/github.com/rails/rails/actionpack/test/controller/log_subscriber_test.rb", 27, 4, 29, 7] F Failure: ACLogSubscriberTest#test_verbose_redirect_logs [test/controller/log_subscriber_test.rb:332]: Expected /\u21B3 \/home\/yahonda\/src\/github.com\/rails\/rails\/actionpack\/test\/controller\/log_subscriber_test.rb:8/ to match "↳ /home/yahonda/src/github.com/rails/rails/actionpack/test/controller/log_subscriber_test.rb:28:in 'Another::LogSubscribersController#redirector'". bin/test test/controller/log_subscriber_test.rb:320 Finished in 0.067459s, 14.8237 runs/s, 44.4712 assertions/s. 1 runs, 3 assertions, 1 failures, 0 errors, 0 skips $ ``` - Ruby 3.4.7 source_location returns an array of 2 elements. ``` $ ruby -v ruby 3.4.7 (2025-10-08 revision 7a5688e2a2) +PRISM [x86_64-linux] $ bin/test test/controller/log_subscriber_test.rb:331 Running 36 tests in a single process (parallelization threshold is 50) Run options: --seed 6959 ["/home/yahonda/src/github.com/rails/rails/actionpack/test/controller/log_subscriber_test.rb", 27] . Finished in 0.067913s, 14.7247 runs/s, 44.1740 assertions/s. 1 runs, 3 assertions, 0 failures, 0 errors, 0 skips $ ``` https://bugs.ruby-lang.org/issues/6012 https://github.com/ruby/ruby/pull/12539 --- actionpack/test/controller/log_subscriber_test.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/actionpack/test/controller/log_subscriber_test.rb b/actionpack/test/controller/log_subscriber_test.rb index 0b6e5b044374e..49912231489db 100644 --- a/actionpack/test/controller/log_subscriber_test.rb +++ b/actionpack/test/controller/log_subscriber_test.rb @@ -318,7 +318,7 @@ def test_filter_redirect_bad_uri end def test_verbose_redirect_logs - line = Another::LogSubscribersController.instance_method(:redirector).source_location.last + 1 + line = Another::LogSubscribersController.instance_method(:redirector).source_location[1] + 1 old_cleaner = ActionController::LogSubscriber.backtrace_cleaner ActionController::LogSubscriber.backtrace_cleaner = ActionController::LogSubscriber.backtrace_cleaner.dup ActionController::LogSubscriber.backtrace_cleaner.add_silencer { |location| !location.include?(__FILE__) } From 958cc2490c500f43862bea8d10b807c8db2ebfa4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Wed, 15 Oct 2025 00:45:42 +0000 Subject: [PATCH 0775/1075] Don't use attestation when sigstore fails See https://github.com/sigstore/sigstore-ruby/issues/263. --- tools/releaser/lib/releaser.rb | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tools/releaser/lib/releaser.rb b/tools/releaser/lib/releaser.rb index 4bdbeef1bf7d4..a33ea1b55bdb0 100644 --- a/tools/releaser/lib/releaser.rb +++ b/tools/releaser/lib/releaser.rb @@ -317,8 +317,14 @@ def npm_otp def gem_otp(gem_path) " --otp " + ykman("rubygems.org") rescue + attestation(gem_path) + end + + def attestation(gem_path) sh "sigstore-cli sign #{gem_path} --bundle #{gem_path}.sigstore.json" " --attestation #{gem_path}.sigstore.json" + rescue + "" end def ykman(service) From 4cf3b7bb80e7e0f0ecdd09baa6bd395b6f2004ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Wed, 15 Oct 2025 00:28:23 +0000 Subject: [PATCH 0776/1075] Start Rails 8.2 development --- .github/stale.yml | 2 +- Gemfile.lock | 106 +- RAILS_VERSION | 2 +- actioncable/CHANGELOG.md | 11 +- actioncable/lib/action_cable/gem_version.rb | 4 +- actioncable/package.json | 4 +- actionmailbox/CHANGELOG.md | 7 +- .../lib/action_mailbox/gem_version.rb | 4 +- actionmailbox/test/dummy/db/schema.rb | 2 +- actionmailer/CHANGELOG.md | 25 +- actionmailer/lib/action_mailer/gem_version.rb | 4 +- actionpack/CHANGELOG.md | 460 +------ actionpack/lib/action_pack/gem_version.rb | 4 +- actiontext/CHANGELOG.md | 63 +- actiontext/lib/action_text/gem_version.rb | 4 +- actiontext/package.json | 2 +- actiontext/test/dummy/db/schema.rb | 2 +- actionview/CHANGELOG.md | 132 +- actionview/lib/action_view/gem_version.rb | 4 +- activejob/CHANGELOG.md | 109 +- activejob/lib/active_job/gem_version.rb | 4 +- activemodel/CHANGELOG.md | 36 +- activemodel/lib/active_model/gem_version.rb | 4 +- activerecord/CHANGELOG.md | 812 +------------ activerecord/README.rdoc | 2 +- .../lib/active_record/associations.rb | 2 +- activerecord/lib/active_record/gem_version.rb | 4 +- activerecord/lib/active_record/migration.rb | 32 +- .../active_record/migration/compatibility.rb | 7 +- .../lib/active_record/model_schema.rb | 2 +- activestorage/CHANGELOG.md | 90 +- .../lib/active_storage/gem_version.rb | 4 +- activestorage/package.json | 4 +- activesupport/CHANGELOG.md | 460 +------ .../lib/active_support/deprecation.rb | 2 +- .../lib/active_support/gem_version.rb | 4 +- guides/CHANGELOG.md | 13 +- guides/source/8_2_release_notes.md | 183 +++ guides/source/_welcome.html.erb | 2 +- guides/source/active_record_basics.md | 4 +- .../active_record_composite_primary_keys.md | 2 +- guides/source/active_record_migrations.md | 62 +- guides/source/active_record_postgresql.md | 2 +- guides/source/association_basics.md | 36 +- guides/source/command_line.md | 6 +- guides/source/configuring.md | 2 + guides/source/debugging_rails_applications.md | 16 +- guides/source/documents.yaml | 6 +- guides/source/getting_started.md | 16 +- .../getting_started_with_devcontainer.md | 2 +- guides/source/layout.html.erb | 2 +- guides/source/upgrading_ruby_on_rails.md | 5 + railties/CHANGELOG.md | 185 +-- .../lib/rails/application/configuration.rb | 2 + railties/lib/rails/gem_version.rb | 4 +- .../new_framework_defaults_8_1.rb.tt | 93 -- .../new_framework_defaults_8_2.rb.tt | 10 + yarn.lock | 1072 ++++------------- 58 files changed, 671 insertions(+), 3474 deletions(-) create mode 100644 guides/source/8_2_release_notes.md delete mode 100644 railties/lib/rails/generators/rails/app/templates/config/initializers/new_framework_defaults_8_1.rb.tt create mode 100644 railties/lib/rails/generators/rails/app/templates/config/initializers/new_framework_defaults_8_2.rb.tt diff --git a/.github/stale.yml b/.github/stale.yml index 2b40308582bee..59876ad5cac31 100644 --- a/.github/stale.yml +++ b/.github/stale.yml @@ -20,7 +20,7 @@ markComment: > The resources of the Rails team are limited, and so we are asking for your help. - If you can still reproduce this error on the `8-0-stable` branch or on `main`, + If you can still reproduce this error on the `8-1-stable` branch or on `main`, please reply with all of the information you have about it in order to keep the issue open. Thank you for all your contributions. diff --git a/Gemfile.lock b/Gemfile.lock index 7a4c7e53ed2db..29b83204b441b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,29 +1,29 @@ PATH remote: . specs: - actioncable (8.1.0.beta1) - actionpack (= 8.1.0.beta1) - activesupport (= 8.1.0.beta1) + actioncable (8.2.0.alpha) + actionpack (= 8.2.0.alpha) + activesupport (= 8.2.0.alpha) nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) - actionmailbox (8.1.0.beta1) - actionpack (= 8.1.0.beta1) - activejob (= 8.1.0.beta1) - activerecord (= 8.1.0.beta1) - activestorage (= 8.1.0.beta1) - activesupport (= 8.1.0.beta1) + actionmailbox (8.2.0.alpha) + actionpack (= 8.2.0.alpha) + activejob (= 8.2.0.alpha) + activerecord (= 8.2.0.alpha) + activestorage (= 8.2.0.alpha) + activesupport (= 8.2.0.alpha) mail (>= 2.8.0) - actionmailer (8.1.0.beta1) - actionpack (= 8.1.0.beta1) - actionview (= 8.1.0.beta1) - activejob (= 8.1.0.beta1) - activesupport (= 8.1.0.beta1) + actionmailer (8.2.0.alpha) + actionpack (= 8.2.0.alpha) + actionview (= 8.2.0.alpha) + activejob (= 8.2.0.alpha) + activesupport (= 8.2.0.alpha) mail (>= 2.8.0) rails-dom-testing (~> 2.2) - actionpack (8.1.0.beta1) - actionview (= 8.1.0.beta1) - activesupport (= 8.1.0.beta1) + actionpack (8.2.0.alpha) + actionview (= 8.2.0.alpha) + activesupport (= 8.2.0.alpha) nokogiri (>= 1.8.5) rack (>= 2.2.4) rack-session (>= 1.0.1) @@ -31,36 +31,36 @@ PATH rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) useragent (~> 0.16) - actiontext (8.1.0.beta1) + actiontext (8.2.0.alpha) action_text-trix (~> 2.1.15) - actionpack (= 8.1.0.beta1) - activerecord (= 8.1.0.beta1) - activestorage (= 8.1.0.beta1) - activesupport (= 8.1.0.beta1) + actionpack (= 8.2.0.alpha) + activerecord (= 8.2.0.alpha) + activestorage (= 8.2.0.alpha) + activesupport (= 8.2.0.alpha) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (8.1.0.beta1) - activesupport (= 8.1.0.beta1) + actionview (8.2.0.alpha) + activesupport (= 8.2.0.alpha) builder (~> 3.1) erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - activejob (8.1.0.beta1) - activesupport (= 8.1.0.beta1) + activejob (8.2.0.alpha) + activesupport (= 8.2.0.alpha) globalid (>= 0.3.6) - activemodel (8.1.0.beta1) - activesupport (= 8.1.0.beta1) - activerecord (8.1.0.beta1) - activemodel (= 8.1.0.beta1) - activesupport (= 8.1.0.beta1) + activemodel (8.2.0.alpha) + activesupport (= 8.2.0.alpha) + activerecord (8.2.0.alpha) + activemodel (= 8.2.0.alpha) + activesupport (= 8.2.0.alpha) timeout (>= 0.4.0) - activestorage (8.1.0.beta1) - actionpack (= 8.1.0.beta1) - activejob (= 8.1.0.beta1) - activerecord (= 8.1.0.beta1) - activesupport (= 8.1.0.beta1) + activestorage (8.2.0.alpha) + actionpack (= 8.2.0.alpha) + activejob (= 8.2.0.alpha) + activerecord (= 8.2.0.alpha) + activesupport (= 8.2.0.alpha) marcel (~> 1.0) - activesupport (8.1.0.beta1) + activesupport (8.2.0.alpha) base64 bigdecimal concurrent-ruby (~> 1.0, >= 1.3.1) @@ -73,23 +73,23 @@ PATH securerandom (>= 0.3) tzinfo (~> 2.0, >= 2.0.5) uri (>= 0.13.1) - rails (8.1.0.beta1) - actioncable (= 8.1.0.beta1) - actionmailbox (= 8.1.0.beta1) - actionmailer (= 8.1.0.beta1) - actionpack (= 8.1.0.beta1) - actiontext (= 8.1.0.beta1) - actionview (= 8.1.0.beta1) - activejob (= 8.1.0.beta1) - activemodel (= 8.1.0.beta1) - activerecord (= 8.1.0.beta1) - activestorage (= 8.1.0.beta1) - activesupport (= 8.1.0.beta1) + rails (8.2.0.alpha) + actioncable (= 8.2.0.alpha) + actionmailbox (= 8.2.0.alpha) + actionmailer (= 8.2.0.alpha) + actionpack (= 8.2.0.alpha) + actiontext (= 8.2.0.alpha) + actionview (= 8.2.0.alpha) + activejob (= 8.2.0.alpha) + activemodel (= 8.2.0.alpha) + activerecord (= 8.2.0.alpha) + activestorage (= 8.2.0.alpha) + activesupport (= 8.2.0.alpha) bundler (>= 1.15.0) - railties (= 8.1.0.beta1) - railties (8.1.0.beta1) - actionpack (= 8.1.0.beta1) - activesupport (= 8.1.0.beta1) + railties (= 8.2.0.alpha) + railties (8.2.0.alpha) + actionpack (= 8.2.0.alpha) + activesupport (= 8.2.0.alpha) irb (~> 1.13) rackup (>= 1.0.0) rake (>= 12.2) diff --git a/RAILS_VERSION b/RAILS_VERSION index 69a59d04b6eff..69640086a3d93 100644 --- a/RAILS_VERSION +++ b/RAILS_VERSION @@ -1 +1 @@ -8.1.0.beta1 +8.2.0.alpha diff --git a/actioncable/CHANGELOG.md b/actioncable/CHANGELOG.md index b4419dd4e9828..ca72310999d75 100644 --- a/actioncable/CHANGELOG.md +++ b/actioncable/CHANGELOG.md @@ -1,11 +1,2 @@ -## Rails 8.1.0.beta1 (September 04, 2025) ## -* Allow passing composite channels to `ActionCable::Channel#stream_for` – e.g. `stream_for [ group, group.owner ]` - - *hey-leon* - -* Allow setting nil as subscription connection identifier for Redis. - - *Nguyen Nguyen* - -Please check [8-0-stable](https://github.com/rails/rails/blob/8-0-stable/actioncable/CHANGELOG.md) for previous changes. +Please check [8-1-stable](https://github.com/rails/rails/blob/8-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 56acb3af95071..0ad4fd88bbeaf 100644 --- a/actioncable/lib/action_cable/gem_version.rb +++ b/actioncable/lib/action_cable/gem_version.rb @@ -10,9 +10,9 @@ def self.gem_version module VERSION MAJOR = 8 - MINOR = 1 + MINOR = 2 TINY = 0 - PRE = "beta1" + PRE = "alpha" STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".") end diff --git a/actioncable/package.json b/actioncable/package.json index 4b5cda4d31ff0..1f1535a5bbdc1 100644 --- a/actioncable/package.json +++ b/actioncable/package.json @@ -1,6 +1,6 @@ { "name": "@rails/actioncable", - "version": "8.1.0-beta1", + "version": "8.2.0-alpha", "description": "WebSocket framework for Ruby on Rails.", "module": "app/assets/javascripts/actioncable.esm.js", "main": "app/assets/javascripts/actioncable.js", @@ -24,7 +24,7 @@ }, "homepage": "https://rubyonrails.org/", "devDependencies": { - "@eslint/js":"^9.24.0", + "@eslint/js": "^9.24.0", "@rollup/plugin-commonjs": "^19.0.1", "@rollup/plugin-node-resolve": "^11.0.1", "eslint": "^9.24.0", diff --git a/actionmailbox/CHANGELOG.md b/actionmailbox/CHANGELOG.md index 4d422ab4dbe0a..a4d7342c05681 100644 --- a/actionmailbox/CHANGELOG.md +++ b/actionmailbox/CHANGELOG.md @@ -1,7 +1,2 @@ -## Rails 8.1.0.beta1 (September 04, 2025) ## -* Add `reply_to_address` extension method on `Mail::Message`. - - *Mr0grog* - -Please check [8-0-stable](https://github.com/rails/rails/blob/8-0-stable/actionmailbox/CHANGELOG.md) for previous changes. +Please check [8-1-stable](https://github.com/rails/rails/blob/8-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 ff6a579f741f5..d6e0f3264a7f6 100644 --- a/actionmailbox/lib/action_mailbox/gem_version.rb +++ b/actionmailbox/lib/action_mailbox/gem_version.rb @@ -8,9 +8,9 @@ def self.gem_version module VERSION MAJOR = 8 - MINOR = 1 + MINOR = 2 TINY = 0 - PRE = "beta1" + PRE = "alpha" STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".") end diff --git a/actionmailbox/test/dummy/db/schema.rb b/actionmailbox/test/dummy/db/schema.rb index acbc0de9d3715..4981e05a0b94c 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[8.1].define(version: 2018_02_12_164506) do +ActiveRecord::Schema[8.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 d8bc9904b2dd3..f911e26cfe54d 100644 --- a/actionmailer/CHANGELOG.md +++ b/actionmailer/CHANGELOG.md @@ -1,25 +1,2 @@ -* Add structured events for Action Mailer: - - `action_mailer.delivered` - - `action_mailer.processed` - *Gannon McGibbon* - -## Rails 8.1.0.beta1 (September 04, 2025) ## - -* Add `deliver_all_later` to enqueue multiple emails at once. - - ```ruby - user_emails = User.all.map { |user| Notifier.welcome(user) } - ActionMailer.deliver_all_later(user_emails) - - # use a custom queue - ActionMailer.deliver_all_later(user_emails, queue: :my_queue) - ``` - - This can greatly reduce the number of round-trips to the queue datastore. - For queue adapters that do not implement the `enqueue_all` method, we - fall back to enqueuing email jobs indvidually. - - *fatkodima* - -Please check [8-0-stable](https://github.com/rails/rails/blob/8-0-stable/actionmailer/CHANGELOG.md) for previous changes. +Please check [8-1-stable](https://github.com/rails/rails/blob/8-1-stable/actionmailer/CHANGELOG.md) for previous changes. diff --git a/actionmailer/lib/action_mailer/gem_version.rb b/actionmailer/lib/action_mailer/gem_version.rb index ac8d0b30339a7..0a366740204f0 100644 --- a/actionmailer/lib/action_mailer/gem_version.rb +++ b/actionmailer/lib/action_mailer/gem_version.rb @@ -8,9 +8,9 @@ def self.gem_version module VERSION MAJOR = 8 - MINOR = 1 + MINOR = 2 TINY = 0 - PRE = "beta1" + PRE = "alpha" STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".") end diff --git a/actionpack/CHANGELOG.md b/actionpack/CHANGELOG.md index d1e8684f2cad8..faadbdf692a9e 100644 --- a/actionpack/CHANGELOG.md +++ b/actionpack/CHANGELOG.md @@ -1,460 +1,2 @@ -* Add link-local IP ranges to `ActionDispatch::RemoteIp` default proxies. - Link-local addresses (`169.254.0.0/16` for IPv4 and `fe80::/10` for IPv6) - are now included in the default trusted proxy list, similar to private IP ranges. - - *Adam Daniels* - -* `remote_ip` will no longer ignore IPs in X-Forwarded-For headers if they - are accompanied by port information. - - *Duncan Brown*, *Prevenios Marinos*, *Masafumi Koba*, *Adam Daniels* - -* Add `action_dispatch.verbose_redirect_logs` setting that logs where redirects were called from. - - Similar to `active_record.verbose_query_logs` and `active_job.verbose_enqueue_logs`, this adds a line in your logs that shows where a redirect was called from. - - Example: - - ``` - Redirected to http://localhost:3000/posts/1 - ↳ app/controllers/posts_controller.rb:32:in `block (2 levels) in create' - ``` - - *Dennis Paagman* - -* Add engine route filtering and better formatting in `bin/rails routes`. - - Allow engine routes to be filterable in the routing inspector, and - improve formatting of engine routing output. - - Before: - ``` - > bin/rails routes -e engine_only - No routes were found for this grep pattern. - For more information about routes, see the Rails guide: https://guides.rubyonrails.org/routing.html. - ``` - - After: - ``` - > bin/rails routes -e engine_only - Routes for application: - No routes were found for this grep pattern. - For more information about routes, see the Rails guide: https://guides.rubyonrails.org/routing.html. - - Routes for Test::Engine: - Prefix Verb URI Pattern Controller#Action - engine GET /engine_only(.:format) a#b - ``` - - *Dennis Paagman*, *Gannon McGibbon* - -* Add structured events for Action Pack and Action Dispatch: - - `action_dispatch.redirect` - - `action_controller.request_started` - - `action_controller.request_completed` - - `action_controller.callback_halted` - - `action_controller.rescue_from_handled` - - `action_controller.file_sent` - - `action_controller.redirected` - - `action_controller.data_sent` - - `action_controller.unpermitted_parameters` - - `action_controller.fragment_cache` - - *Adrianna Chang* - -* URL helpers for engines mounted at the application root handle `SCRIPT_NAME` correctly. - - Fixed an issue where `SCRIPT_NAME` is not applied to paths generated for routes in an engine - mounted at "/". - - *Mike Dalessio* - -* Update `ActionController::Metal::RateLimiting` to support passing method names to `:by` and `:with` - - ```ruby - class SignupsController < ApplicationController - rate_limit to: 10, within: 1.minute, with: :redirect_with_flash - - private - def redirect_with_flash - redirect_to root_url, alert: "Too many requests!" - end - end - ``` - - *Sean Doyle* - -* Optimize `ActionDispatch::Http::URL.build_host_url` when protocol is included in host. - - When using URL helpers with a host that includes the protocol (e.g., `{ host: "https://example.com" }`), - skip unnecessary protocol normalization and string duplication since the extracted protocol is already - in the correct format. This eliminates 2 string allocations per URL generation and provides a ~10% - performance improvement for this case. - - *Joshua Young*, *Hartley McGuire* - -* Allow `action_controller.logger` to be disabled by setting it to `nil` or `false` instead of always defaulting to `Rails.logger`. - - *Roberto Miranda* - -## Rails 8.1.0.beta1 (September 04, 2025) ## - -* Remove deprecated support to a route to multiple paths. - - *Rafael Mendonça França* - -* Remove deprecated support for using semicolons as a query string separator. - - Before: - - ```ruby - ActionDispatch::QueryParser.each_pair("foo=bar;baz=quux").to_a - # => [["foo", "bar"], ["baz", "quux"]] - ``` - - After: - - ```ruby - ActionDispatch::QueryParser.each_pair("foo=bar;baz=quux").to_a - # => [["foo", "bar;baz=quux"]] - ``` - - *Rafael Mendonça França* - -* Remove deprecated support to skipping over leading brackets in parameter names in the parameter parser. - - Before: - - ```ruby - ActionDispatch::ParamBuilder.from_query_string("[foo]=bar") # => { "foo" => "bar" } - ActionDispatch::ParamBuilder.from_query_string("[foo][bar]=baz") # => { "foo" => { "bar" => "baz" } } - ``` - - After: - - ```ruby - ActionDispatch::ParamBuilder.from_query_string("[foo]=bar") # => { "[foo]" => "bar" } - ActionDispatch::ParamBuilder.from_query_string("[foo][bar]=baz") # => { "[foo]" => { "bar" => "baz" } } - ``` - - *Rafael Mendonça França* - -* Deprecate `Rails.application.config.action_dispatch.ignore_leading_brackets`. - - *Rafael Mendonça França* - -* Raise `ActionController::TooManyRequests` error from `ActionController::RateLimiting` - - Requests that exceed the rate limit raise an `ActionController::TooManyRequests` error. - By default, Action Dispatch rescues the error and responds with a `429 Too Many Requests` status. - - *Sean Doyle* - -* Add .md/.markdown as Markdown extensions and add a default `markdown:` renderer: - - ```ruby - class Page - def to_markdown - body - end - end - - class PagesController < ActionController::Base - def show - @page = Page.find(params[:id]) - - respond_to do |format| - format.html - format.md { render markdown: @page } - end - end - end - ``` - - *DHH* - -* Add headers to engine routes inspection command - - *Petrik de Heus* - -* Add "Copy as text" button to error pages - - *Mikkel Malmberg* - -* Add `scope:` option to `rate_limit` method. - - Previously, it was not possible to share a rate limit count between several controllers, since the count was by - default separate for each controller. - - Now, the `scope:` option solves this problem. - - ```ruby - class APIController < ActionController::API - rate_limit to: 2, within: 2.seconds, scope: "api" - end - - class API::PostsController < APIController - # ... - end - - class API::UsersController < APIController - # ... - end - ``` - - *ArthurPV*, *Kamil Hanus* - -* Add support for `rack.response_finished` callbacks in ActionDispatch::Executor. - - The executor middleware now supports deferring completion callbacks to later - in the request lifecycle by utilizing Rack's `rack.response_finished` mechanism, - when available. This enables applications to define `rack.response_finished` callbacks - that may rely on state that would be cleaned up by the executor's completion callbacks. - - *Adrianna Chang*, *Hartley McGuire* - -* Produce a log when `rescue_from` is invoked. - - *Steven Webb*, *Jean Boussier* - -* Allow hosts redirects from `hosts` Rails configuration - - ```ruby - config.action_controller.allowed_redirect_hosts << "example.com" - ``` - - *Kevin Robatel* - -* `rate_limit.action_controller` notification has additional payload - - additional values: count, to, within, by, name, cache_key - - *Jonathan Rochkind* - -* Add JSON support to the built-in health controller. - - The health controller now responds to JSON requests with a structured response - containing status and timestamp information. This makes it easier for monitoring - tools and load balancers to consume health check data programmatically. - - ```ruby - # /up.json - { - "status": "up", - "timestamp": "2025-09-19T12:00:00Z" - } - ``` - - *Francesco Loreti*, *Juan Vásquez* - -* Allow to open source file with a crash from the browser. - - *Igor Kasyanchuk* - -* Always check query string keys for valid encoding just like values are checked. - - *Casper Smits* - -* Always return empty body for HEAD requests in `PublicExceptions` and - `DebugExceptions`. - - This is required by `Rack::Lint` (per RFC9110). - - *Hartley McGuire* - -* Add comprehensive support for HTTP Cache-Control request directives according to RFC 9111. - - Provides a `request.cache_control_directives` object that gives access to request cache directives: - - ```ruby - # Boolean directives - request.cache_control_directives.only_if_cached? # => true/false - request.cache_control_directives.no_cache? # => true/false - request.cache_control_directives.no_store? # => true/false - request.cache_control_directives.no_transform? # => true/false - - # Value directives - request.cache_control_directives.max_age # => integer or nil - request.cache_control_directives.max_stale # => integer or nil (or true for valueless max-stale) - request.cache_control_directives.min_fresh # => integer or nil - request.cache_control_directives.stale_if_error # => integer or nil - - # Special helpers for max-stale - request.cache_control_directives.max_stale? # => true if max-stale present (with or without value) - request.cache_control_directives.max_stale_unlimited? # => true only for valueless max-stale - ``` - - Example usage: - - ```ruby - def show - if request.cache_control_directives.only_if_cached? - @article = Article.find_cached(params[:id]) - return head(:gateway_timeout) if @article.nil? - else - @article = Article.find(params[:id]) - end - - render :show - end - ``` - - *egg528* - -* Add assert_in_body/assert_not_in_body as the simplest way to check if a piece of text is in the response body. - - *DHH* - -* Include cookie name when calculating maximum allowed size. - - *Hartley McGuire* - -* Implement `must-understand` directive according to RFC 9111. - - The `must-understand` directive indicates that a cache must understand the semantics of the response status code, or discard the response. This directive is enforced to be used only with `no-store` to ensure proper cache behavior. - - ```ruby - class ArticlesController < ApplicationController - def show - @article = Article.find(params[:id]) - - if @article.special_format? - must_understand - render status: 203 # Non-Authoritative Information - else - fresh_when @article - end - end - end - ``` - - *heka1024* - -* The JSON renderer doesn't escape HTML entities or Unicode line separators anymore. - - Using `render json:` will no longer escape `<`, `>`, `&`, `U+2028` and `U+2029` characters that can cause errors - when the resulting JSON is embedded in JavaScript, or vulnerabilities when the resulting JSON is embedded in HTML. - - Since the renderer is used to return a JSON document as `application/json`, it's typically not necessary to escape - those characters, and it improves performance. - - Escaping will still occur when the `:callback` option is set, since the JSON is used as JavaScript code in this - situation (JSONP). - - You can use the `:escape` option or set `config.action_controller.escape_json_responses` to `true` to restore the - escaping behavior. - - ```ruby - class PostsController < ApplicationController - def index - render json: Post.last(30), escape: true - end - end - ``` - - *Étienne Barrié*, *Jean Boussier* - -* Load lazy route sets before inserting test routes - - Without loading lazy route sets early, we miss `after_routes_loaded` callbacks, or risk - invoking them with the test routes instead of the real ones if another load is triggered by an engine. - - *Gannon McGibbon* - -* Raise `AbstractController::DoubleRenderError` if `head` is called after rendering. - - After this change, invoking `head` will lead to an error if response body is already set: - - ```ruby - class PostController < ApplicationController - def index - render locals: {} - head :ok - end - end - ``` - - *Iaroslav Kurbatov* - -* The Cookie Serializer can now serialize an Active Support SafeBuffer when using message pack. - - Such code would previously produce an error if an application was using messagepack as its cookie serializer. - - ```ruby - class PostController < ApplicationController - def index - flash.notice = t(:hello_html) # This would try to serialize a SafeBuffer, which was not possible. - end - end - ``` - - *Edouard Chin* - -* Fix `Rails.application.reload_routes!` from clearing almost all routes. - - When calling `Rails.application.reload_routes!` inside a middleware of - a Rake task, it was possible under certain conditions that all routes would be cleared. - If ran inside a middleware, this would result in getting a 404 on most page you visit. - This issue was only happening in development. - - *Edouard Chin* - -* Add resource name to the `ArgumentError` that's raised when invalid `:only` or `:except` options are given to `#resource` or `#resources` - - This makes it easier to locate the source of the problem, especially for routes drawn by gems. - - Before: - ``` - :only and :except must include only [:index, :create, :new, :show, :update, :destroy, :edit], but also included [:foo, :bar] - ``` - - After: - ``` - Route `resources :products` - :only and :except must include only [:index, :create, :new, :show, :update, :destroy, :edit], but also included [:foo, :bar] - ``` - - *Jeremy Green* - -* A route pointing to a non-existing controller now returns a 500 instead of a 404. - - A controller not existing isn't a routing error that should result - in a 404, but a programming error that should result in a 500 and - be reported. - - Until recently, this was hard to untangle because of the support - for dynamic `:controller` segment in routes, but since this is - deprecated and will be removed in Rails 8.1, we can now easily - not consider missing controllers as routing errors. - - *Jean Boussier* - -* Add `check_collisions` option to `ActionDispatch::Session::CacheStore`. - - Newly generated session ids use 128 bits of randomness, which is more than - enough to ensure collisions can't happen, but if you need to harden sessions - even more, you can enable this option to check in the session store that the id - is indeed free you can enable that option. This however incurs an extra write - on session creation. - - *Shia* - -* In ExceptionWrapper, match backtrace lines with built templates more often, - allowing improved highlighting of errors within do-end blocks in templates. - Fix for Ruby 3.4 to match new method labels in backtrace. - - *Martin Emde* - -* Allow setting content type with a symbol of the Mime type. - - ```ruby - # Before - response.content_type = "text/html" - - # After - response.content_type = :html - ``` - - *Petrik de Heus* - -Please check [8-0-stable](https://github.com/rails/rails/blob/8-0-stable/actionpack/CHANGELOG.md) for previous changes. +Please check [8-1-stable](https://github.com/rails/rails/blob/8-1-stable/actionpack/CHANGELOG.md) for previous changes. diff --git a/actionpack/lib/action_pack/gem_version.rb b/actionpack/lib/action_pack/gem_version.rb index 85c38f50ba794..a812190bf5d8e 100644 --- a/actionpack/lib/action_pack/gem_version.rb +++ b/actionpack/lib/action_pack/gem_version.rb @@ -10,9 +10,9 @@ def self.gem_version module VERSION MAJOR = 8 - MINOR = 1 + MINOR = 2 TINY = 0 - PRE = "beta1" + PRE = "alpha" STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".") end diff --git a/actiontext/CHANGELOG.md b/actiontext/CHANGELOG.md index 31cd7aa12b4f1..cae3490d3818a 100644 --- a/actiontext/CHANGELOG.md +++ b/actiontext/CHANGELOG.md @@ -1,63 +1,2 @@ -* De-couple `@rails/actiontext/attachment_upload.js` from `Trix.Attachment` - Implement `@rails/actiontext/index.js` with a `direct-upload:progress` event - listeners and `Promise` resolution. - - *Sean Doyle* - -* Capture block content for form helper methods - - ```erb - <%= rich_textarea_tag :content, nil do %> -

hello world

- <% end %> - - - <%= rich_textarea :message, :content, input: "trix_input_1" do %> -

hello world

- <% end %> - - - <%= form_with model: Message.new do |form| %> - <%= form.rich_textarea :content do %> -

hello world

- <% end %> - <% end %> - - ``` - - *Sean Doyle* - -* Generalize `:rich_text_area` Capybara selector - - Prepare for more Action Text-capable WYSIWYG editors by making - `:rich_text_area` rely on the presence of `[role="textbox"]` and - `[contenteditable]` HTML attributes rather than a `` element. - - *Sean Doyle* - -## Rails 8.1.0.beta1 (September 04, 2025) ## - -* Forward `fill_in_rich_text_area` options to Capybara - - ```ruby - fill_in_rich_textarea "Rich text editor", id: "trix_editor_1", with: "Hello world!" - ``` - - *Sean Doyle* - -* Attachment upload progress accounts for server processing time. - - *Jeremy Daer* - -* The Trix dependency is now satisfied by a gem, `action_text-trix`, rather than vendored - files. This allows applications to bump Trix versions independently of Rails - releases. Effectively this also upgrades Trix to `>= 2.1.15`. - - *Mike Dalessio* - -* Change `ActionText::RichText#embeds` assignment from `before_save` to `before_validation` - - *Sean Doyle* - -Please check [8-0-stable](https://github.com/rails/rails/blob/8-0-stable/actiontext/CHANGELOG.md) for previous changes. +Please check [8-1-stable](https://github.com/rails/rails/blob/8-1-stable/actiontext/CHANGELOG.md) for previous changes. diff --git a/actiontext/lib/action_text/gem_version.rb b/actiontext/lib/action_text/gem_version.rb index 8a1c20b84d680..9bad1e6af8884 100644 --- a/actiontext/lib/action_text/gem_version.rb +++ b/actiontext/lib/action_text/gem_version.rb @@ -10,9 +10,9 @@ def self.gem_version module VERSION MAJOR = 8 - MINOR = 1 + MINOR = 2 TINY = 0 - PRE = "beta1" + PRE = "alpha" STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".") end diff --git a/actiontext/package.json b/actiontext/package.json index 5e63e60ac7052..51de2cdac5927 100644 --- a/actiontext/package.json +++ b/actiontext/package.json @@ -1,6 +1,6 @@ { "name": "@rails/actiontext", - "version": "8.1.0-beta1", + "version": "8.2.0-alpha", "description": "Edit and display rich text in Rails applications", "module": "app/assets/javascripts/actiontext.esm.js", "main": "app/assets/javascripts/actiontext.js", diff --git a/actiontext/test/dummy/db/schema.rb b/actiontext/test/dummy/db/schema.rb index 2e3f39ad970af..8ad24850b0635 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[8.1].define(version: 2019_03_17_200724) do +ActiveRecord::Schema[8.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/actionview/CHANGELOG.md b/actionview/CHANGELOG.md index 3cbabf761e508..ac7aaa27c78cf 100644 --- a/actionview/CHANGELOG.md +++ b/actionview/CHANGELOG.md @@ -1,132 +1,2 @@ -* The BEGIN template annotation/comment was previously printed on the same line as the following element. We now insert a newline inside the comment so it spans two lines without adding visible whitespace to the HTML output to enhance readability. - Before: - ``` -

This is grand!

- ``` - - After: - ``` -

This is grand!

- ``` - *Emmanuel Hayford* - -* Add structured events for Action View: - - `action_view.render_template` - - `action_view.render_partial` - - `action_view.render_layout` - - `action_view.render_collection` - - `action_view.render_start` - - *Gannon McGibbon* - -* Fix label with `for` option not getting prefixed by form `namespace` value - - *Abeid Ahmed*, *Hartley McGuire* - -* Add `fetchpriority` to Link headers to match HTML generated by `preload_link_tag`. - - *Guillermo Iguaran* - -## Rails 8.1.0.beta1 (September 04, 2025) ## - -* Add CSP `nonce` to Link headers generated by `preload_link_tag`. - - *Alexander Gitter* - -* Allow `current_page?` to match against specific HTTP method(s) with a `method:` option. - - *Ben Sheldon* - -* Remove `autocomplete="off"` on hidden inputs generated by the following - tags: - - * `form_tag`, `token_tag`, `method_tag` - - As well as the hidden parameter fields included in `button_to`, - `check_box`, `select` (with `multiple`) and `file_field` forms. - - *nkulway* - -* Enable configuring the strategy for tracking dependencies between Action - View templates. - - The existing `:regex` strategy is kept as the default, but with - `load_defaults 8.1` the strategy will be `:ruby` (using a real Ruby parser). - - *Hartley McGuire* - -* Introduce `relative_time_in_words` helper - - ```ruby - relative_time_in_words(3.minutes.from_now) # => "in 3 minutes" - relative_time_in_words(3.minutes.ago) # => "3 minutes ago" - relative_time_in_words(10.seconds.ago, include_seconds: true) # => "less than 10 seconds ago" - ``` - - *Matheus Richard* - -* Make `nonce: false` remove the nonce attribute from `javascript_tag`, `javascript_include_tag`, and `stylesheet_link_tag`. - - *francktrouillez* - -* Add `dom_target` helper to create `dom_id`-like strings from an unlimited - number of objects. - - *Ben Sheldon* - -* Respect `html_options[:form]` when `collection_checkboxes` generates the - hidden ``. - - *Riccardo Odone* - -* Layouts have access to local variables passed to `render`. - - This fixes #31680 which was a regression in Rails 5.1. - - *Mike Dalessio* - -* Argument errors related to strict locals in templates now raise an - `ActionView::StrictLocalsError`, and all other argument errors are reraised as-is. - - Previously, any `ArgumentError` raised during template rendering was swallowed during strict - local error handling, so that an `ArgumentError` unrelated to strict locals (e.g., a helper - method invoked with incorrect arguments) would be replaced by a similar `ArgumentError` with an - unrelated backtrace, making it difficult to debug templates. - - Now, any `ArgumentError` unrelated to strict locals is reraised, preserving the original - backtrace for developers. - - Also note that `ActionView::StrictLocalsError` is a subclass of `ArgumentError`, so any existing - code that rescues `ArgumentError` will continue to work. - - Fixes #52227. - - *Mike Dalessio* - -* Improve error highlighting of multi-line methods in ERB templates or - templates where the error occurs within a do-end block. - - *Martin Emde* - -* Fix a crash in ERB template error highlighting when the error occurs on a - line in the compiled template that is past the end of the source template. - - *Martin Emde* - -* Improve reliability of ERB template error highlighting. - Fix infinite loops and crashes in highlighting and - improve tolerance for alternate ERB handlers. - - *Martin Emde* - -* Allow `hidden_field` and `hidden_field_tag` to accept a custom autocomplete value. - - *brendon* - -* Add a new configuration `content_security_policy_nonce_auto` for automatically adding a nonce to the tags affected by the directives specified by the `content_security_policy_nonce_directives` configuration option. - - *francktrouillez* - -Please check [8-0-stable](https://github.com/rails/rails/blob/8-0-stable/actionview/CHANGELOG.md) for previous changes. +Please check [8-1-stable](https://github.com/rails/rails/blob/8-1-stable/actionview/CHANGELOG.md) for previous changes. diff --git a/actionview/lib/action_view/gem_version.rb b/actionview/lib/action_view/gem_version.rb index 0e50f57e6b3af..bbcf66d8d7615 100644 --- a/actionview/lib/action_view/gem_version.rb +++ b/actionview/lib/action_view/gem_version.rb @@ -8,9 +8,9 @@ def self.gem_version module VERSION MAJOR = 8 - MINOR = 1 + MINOR = 2 TINY = 0 - PRE = "beta1" + PRE = "alpha" STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".") end diff --git a/activejob/CHANGELOG.md b/activejob/CHANGELOG.md index 5c1085e38962d..b1f9579553ab9 100644 --- a/activejob/CHANGELOG.md +++ b/activejob/CHANGELOG.md @@ -1,109 +1,2 @@ -* Add structured events for Active Job: - - `active_job.enqueued` - - `active_job.bulk_enqueued` - - `active_job.started` - - `active_job.completed` - - `active_job.retry_scheduled` - - `active_job.retry_stopped` - - `active_job.discarded` - - `active_job.interrupt` - - `active_job.resume` - - `active_job.step_skipped` - - `active_job.step_started` - - `active_job.step` - *Adrianna Chang* - -## Rails 8.1.0.beta1 (September 04, 2025) ## - -* Deprecate built-in `sidekiq` adapter. - - If you're using this adapter, upgrade to `sidekiq` 7.3.3 or later to use the `sidekiq` gem's adapter. - - *fatkodima* - -* Remove deprecated internal `SuckerPunch` adapter in favor of the adapter included with the `sucker_punch` gem. - - *Rafael Mendonça França* - -* Remove support to set `ActiveJob::Base.enqueue_after_transaction_commit` to `:never`, `:always` and `:default`. - - *Rafael Mendonça França* - -* Remove deprecated `Rails.application.config.active_job.enqueue_after_transaction_commit`. - - *Rafael Mendonça França* - -* `ActiveJob::Serializers::ObjectSerializers#klass` method is now public. - - Custom Active Job serializers must have a public `#klass` method too. - The returned class will be index allowing for faster serialization. - - *Jean Boussier* - -* Allow jobs to the interrupted and resumed with Continuations - - A job can use Continuations by including the `ActiveJob::Continuable` - concern. Continuations split jobs into steps. When the queuing system - is shutting down jobs can be interrupted and their progress saved. - - ```ruby - class ProcessImportJob - include ActiveJob::Continuable - - def perform(import_id) - @import = Import.find(import_id) - - # block format - step :initialize do - @import.initialize - end - - # step with cursor, the cursor is saved when the job is interrupted - step :process do |step| - @import.records.find_each(start: step.cursor) do |record| - record.process - step.advance! from: record.id - end - end - - # method format - step :finalize - - private - def finalize - @import.finalize - end - end - end - ``` - - *Donal McBreen* - -* Defer invocation of ActiveJob enqueue callbacks until after commit when - `enqueue_after_transaction_commit` is enabled. - - *Will Roever* - -* Add `report:` option to `ActiveJob::Base#retry_on` and `#discard_on` - - When the `report:` option is passed, errors will be reported to the error reporter - before being retried / discarded. - - *Andrew Novoselac* - -* Accept a block for `ActiveJob::ConfiguredJob#perform_later`. - - This was inconsistent with a regular `ActiveJob::Base#perform_later`. - - *fatkodima* - -* Raise a more specific error during deserialization when a previously serialized job class is now unknown. - - `ActiveJob::UnknownJobClassError` will be raised instead of a more generic - `NameError` to make it easily possible for adapters to tell if the `NameError` - was raised during job execution or deserialization. - - *Earlopain* - -Please check [8-0-stable](https://github.com/rails/rails/blob/8-0-stable/activejob/CHANGELOG.md) for previous changes. +Please check [8-1-stable](https://github.com/rails/rails/blob/8-1-stable/activejob/CHANGELOG.md) for previous changes. diff --git a/activejob/lib/active_job/gem_version.rb b/activejob/lib/active_job/gem_version.rb index 63e87e8e2e8f2..ffdfd191804f7 100644 --- a/activejob/lib/active_job/gem_version.rb +++ b/activejob/lib/active_job/gem_version.rb @@ -8,9 +8,9 @@ def self.gem_version module VERSION MAJOR = 8 - MINOR = 1 + MINOR = 2 TINY = 0 - PRE = "beta1" + PRE = "alpha" STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".") end diff --git a/activemodel/CHANGELOG.md b/activemodel/CHANGELOG.md index 1cd4a4a32d6ef..28b5f4f36c802 100644 --- a/activemodel/CHANGELOG.md +++ b/activemodel/CHANGELOG.md @@ -1,36 +1,2 @@ -* Add `reset_token: { expires_in: ... }` option to `has_secure_password`. - Allows configuring the expiry duration of password reset tokens (default remains 15 minutes for backwards compatibility). - - ```ruby - has_secure_password reset_token: { expires_in: 1.hour } - ``` - - *Jevin Sew*, *Abeid Ahmed* - -## Rails 8.1.0.beta1 (September 04, 2025) ## - -* Add `except_on:` option for validation callbacks. - - *Ben Sheldon* - -* Backport `ActiveRecord::Normalization` to `ActiveModel::Attributes::Normalization` - - ```ruby - class User - include ActiveModel::Attributes - include ActiveModel::Attributes::Normalization - - attribute :email, :string - - normalizes :email, with: -> email { email.strip.downcase } - end - - user = User.new - user.email = " CRUISE-CONTROL@EXAMPLE.COM\n" - user.email # => "cruise-control@example.com" - ``` - - *Sean Doyle* - -Please check [8-0-stable](https://github.com/rails/rails/blob/8-0-stable/activemodel/CHANGELOG.md) for previous changes. +Please check [8-1-stable](https://github.com/rails/rails/blob/8-1-stable/activemodel/CHANGELOG.md) for previous changes. diff --git a/activemodel/lib/active_model/gem_version.rb b/activemodel/lib/active_model/gem_version.rb index 986d3f802d984..ad3e59161501c 100644 --- a/activemodel/lib/active_model/gem_version.rb +++ b/activemodel/lib/active_model/gem_version.rb @@ -8,9 +8,9 @@ def self.gem_version module VERSION MAJOR = 8 - MINOR = 1 + MINOR = 2 TINY = 0 - PRE = "beta1" + PRE = "alpha" STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".") end diff --git a/activerecord/CHANGELOG.md b/activerecord/CHANGELOG.md index 05be2f593aa94..217c0b0118811 100644 --- a/activerecord/CHANGELOG.md +++ b/activerecord/CHANGELOG.md @@ -1,812 +1,2 @@ -* Add replicas to test database parallelization setup. - Setup and configuration of databases for parallel testing now includes replicas. - - This fixes an issue when using a replica database, database selector middleware, - and non-transactional tests, where integration tests running in parallel would select - the base test database, i.e. `db_test`, instead of the numbered parallel worker database, - i.e. `db_test_{n}`. - - *Adam Maas* - -* Support virtual (not persisted) generated columns on PostgreSQL 18+ - - PostgreSQL 18 introduces virtual (not persisted) generated columns, - which are now the default unless the `stored: true` option is explicitly specified on PostgreSQL 18+. - - ```ruby - create_table :users do |t| - t.string :name - t.virtual :lower_name, type: :string, as: "LOWER(name)", stored: false - t.virtual :name_length, type: :integer, as: "LENGTH(name)" - end - ``` - - *Yasuo Honda* - -* Optimize schema dumping to prevent duplicate file generation. - - `ActiveRecord::Tasks::DatabaseTasks.dump_all` now tracks which schema files - have already been dumped and skips dumping the same file multiple times. - This improves performance when multiple database configurations share the - same schema dump path. - - *Mikey Gough*, *Hartley McGuire* - -* Add structured events for Active Record: - - `active_record.strict_loading_violation` - - `active_record.sql` - - *Gannon McGibbon* - -* Add support for integer shard keys. - ```ruby - # Now accepts symbols as shard keys. - ActiveRecord::Base.connects_to(shards: { - 1: { writing: :primary_shard_one, reading: :primary_shard_one }, - 2: { writing: :primary_shard_two, reading: :primary_shard_two}, - }) - - ActiveRecord::Base.connected_to(shard: 1) do - # .. - end - ``` - - *Nony Dutton* - -* Add `ActiveRecord::Base.only_columns` - - Similar in use case to `ignored_columns` but listing columns to consider rather than the ones - to ignore. - - Can be useful when working with a legacy or shared database schema, or to make safe schema change - in two deploys rather than three. - - *Anton Kandratski* - -* Use `PG::Connection#close_prepared` (protocol level Close) to deallocate - prepared statements when available. - - To enable its use, you must have pg >= 1.6.0, libpq >= 17, and a PostgreSQL - database version >= 17. - - *Hartley McGuire*, *Andrew Jackson* - -* Fix query cache for pinned connections in multi threaded transactional tests - - When a pinned connection is used across separate threads, they now use a separate cache store - for each thread. - - This improve accuracy of system tests, and any test using multiple threads. - - *Heinrich Lee Yu*, *Jean Boussier* - -* Fix time attribute dirty tracking with timezone conversions. - - Time-only attributes now maintain a fixed date of 2000-01-01 during timezone conversions, - preventing them from being incorrectly marked as changed due to date shifts. - - This fixes an issue where time attributes would be marked as changed when setting the same time value - due to timezone conversion causing internal date shifts. - - *Prateek Choudhary* - -* Skip calling `PG::Connection#cancel` in `cancel_any_running_query` - when using libpq >= 18 with pg < 1.6.0, due to incompatibility. - Rollback still runs, but may take longer. - - *Yasuo Honda*, *Lars Kanis* - -* Don't add `id_value` attribute alias when attribute/column with that name already exists. - - *Rob Lewis* - -## Rails 8.1.0.beta1 (September 04, 2025) ## - -* Remove deprecated `:unsigned_float` and `:unsigned_decimal` column methods for MySQL. - - *Rafael Mendonça França* - -* Remove deprecated `:retries` option for the SQLite3 adapter. - - *Rafael Mendonça França* - -* Introduce new database configuration options `keepalive`, `max_age`, and - `min_connections` -- and rename `pool` to `max_connections` to match. - - There are no changes to default behavior, but these allow for more specific - control over pool behavior. - - *Matthew Draper*, *Chris AtLee*, *Rachael Wright-Munn* - -* Move `LIMIT` validation from query generation to when `limit()` is called. - - *Hartley McGuire*, *Shuyang* - -* Add `ActiveRecord::CheckViolation` error class for check constraint violations. - - *Ryuta Kamizono* - -* Add `ActiveRecord::ExclusionViolation` error class for exclusion constraint violations. - - When an exclusion constraint is violated in PostgreSQL, the error will now be raised - as `ActiveRecord::ExclusionViolation` instead of the generic `ActiveRecord::StatementInvalid`, - making it easier to handle these specific constraint violations in application code. - - This follows the same pattern as other constraint violation error classes like - `RecordNotUnique` for unique constraint violations and `InvalidForeignKey` for - foreign key constraint violations. - - *Ryuta Kamizono* - -* Attributes filtered by `filter_attributes` will now also be filtered by `filter_parameters` - so sensitive information is not leaked. - - *Jill Klang* - -* Add `connection.current_transaction.isolation` API to check current transaction's isolation level. - - Returns the isolation level if it was explicitly set via the `isolation:` parameter - or through `ActiveRecord.with_transaction_isolation_level`, otherwise returns `nil`. - Nested transactions return the parent transaction's isolation level. - - ```ruby - # Returns nil when no transaction - User.connection.current_transaction.isolation # => nil - - # Returns explicitly set isolation level - User.transaction(isolation: :serializable) do - User.connection.current_transaction.isolation # => :serializable - end - - # Returns nil when isolation not explicitly set - User.transaction do - User.connection.current_transaction.isolation # => nil - end - - # Nested transactions inherit parent's isolation - User.transaction(isolation: :read_committed) do - User.transaction do - User.connection.current_transaction.isolation # => :read_committed - end - end - ``` - - *Kir Shatrov* - -* Fix `#merge` with `#or` or `#and` and a mixture of attributes and SQL strings resulting in an incorrect query. - - ```ruby - base = Comment.joins(:post).where(user_id: 1).where("recent = 1") - puts base.merge(base.where(draft: true).or(Post.where(archived: true))).to_sql - ``` - - Before: - - ```SQL - SELECT "comments".* FROM "comments" - INNER JOIN "posts" ON "posts"."id" = "comments"."post_id" - WHERE (recent = 1) - AND ( - "comments"."user_id" = 1 - AND (recent = 1) - AND "comments"."draft" = 1 - OR "posts"."archived" = 1 - ) - ``` - - After: - - ```SQL - SELECT "comments".* FROM "comments" - INNER JOIN "posts" ON "posts"."id" = "comments"."post_id" - WHERE "comments"."user_id" = 1 - AND (recent = 1) - AND ( - "comments"."user_id" = 1 - AND (recent = 1) - AND "comments"."draft" = 1 - OR "posts"."archived" = 1 - ) - ``` - - *Joshua Young* - -* Make schema dumper to account for `ActiveRecord.dump_schemas` when dumping in `:ruby` format. - - *fatkodima* - -* Add `:touch` option to `update_column`/`update_columns` methods. - - ```ruby - # Will update :updated_at/:updated_on alongside :nice column. - user.update_column(:nice, true, touch: true) - - # Will update :updated_at/:updated_on alongside :last_ip column - user.update_columns(last_ip: request.remote_ip, touch: true) - ``` - - *Dmitrii Ivliev* - -* Optimize Active Record batching further when using ranges. - - Tested on a PostgreSQL table with 10M records and batches of 10k records, the generation - of relations for the 1000 batches was `4.8x` faster (`6.8s` vs. `1.4s`), used `900x` - less bandwidth (`180MB` vs. `0.2MB`) and allocated `45x` less memory (`490MB` vs. `11MB`). - - *Maxime Réty*, *fatkodima* - -* Include current character length in error messages for index and table name length validations. - - *Joshua Young* - -* Add `rename_schema` method for PostgreSQL. - - *T S Vallender* - -* Implement support for deprecating associations: - - ```ruby - has_many :posts, deprecated: true - ``` - - With that, Active Record will report any usage of the `posts` association. - - Three reporting modes are supported (`:warn`, `:raise`, and `:notify`), and - backtraces can be enabled or disabled. Defaults are `:warn` mode and - disabled backtraces. - - Please, check the docs for further details. - - *Xavier Noria* - -* PostgreSQL adapter create DB now supports `locale_provider` and `locale`. - - *Bengt-Ove Hollaender* - -* Use ntuples to populate row_count instead of count for Postgres - - *Jonathan Calvert* - -* Fix checking whether an unpersisted record is `include?`d in a strictly - loaded `has_and_belongs_to_many` association. - - *Hartley McGuire* - -* Add ability to change transaction isolation for all pools within a block. - - This functionality is useful if your application needs to change the database - transaction isolation for a request or action. - - Calling `ActiveRecord.with_transaction_isolation_level(level) {}` in an around filter or - middleware will set the transaction isolation for all pools accessed within the block, - but not for the pools that aren't. - - This works with explicit and implicit transactions: - - ```ruby - ActiveRecord.with_transaction_isolation_level(:read_committed) do - Tag.transaction do # opens a transaction explicitly - Tag.create! - end - end - ``` - - ```ruby - ActiveRecord.with_transaction_isolation_level(:read_committed) do - Tag.create! # opens a transaction implicitly - end - ``` - - *Eileen M. Uchitelle* - -* Raise `ActiveRecord::MissingRequiredOrderError` when order dependent finder methods (e.g. `#first`, `#last`) are - called without `order` values on the relation, and the model does not have any order columns (`implicit_order_column`, - `query_constraints`, or `primary_key`) to fall back on. - - This change will be introduced with a new framework default for Rails 8.1, and the current behavior of not raising - an error has been deprecated with the aim of removing the configuration option in Rails 8.2. - - ```ruby - config.active_record.raise_on_missing_required_finder_order_columns = true - ``` - - *Joshua Young* - -* `:class_name` is now invalid in polymorphic `belongs_to` associations. - - Reason is `:class_name` does not make sense in those associations because - the class name of target records is dynamic and stored in the type column. - - Existing polymorphic associations setting this option can just delete it. - While it did not raise, it had no effect anyway. - - *Xavier Noria* - -* Add support for multiple databases to `db:migrate:reset`. - - *Joé Dupuis* - -* Add `affected_rows` to `ActiveRecord::Result`. - - *Jenny Shen* - -* Enable passing retryable SqlLiterals to `#where`. - - *Hartley McGuire* - -* Set default for primary keys in `insert_all`/`upsert_all`. - - Previously in Postgres, updating and inserting new records in one upsert wasn't possible - due to null primary key values. `nil` primary key values passed into `insert_all`/`upsert_all` - are now implicitly set to the default insert value specified by adapter. - - *Jenny Shen* - -* Add a load hook `active_record_database_configurations` for `ActiveRecord::DatabaseConfigurations` - - *Mike Dalessio* - -* Use `TRUE` and `FALSE` for SQLite queries with boolean columns. - - *Hartley McGuire* - -* Bump minimum supported SQLite to 3.23.0. - - *Hartley McGuire* - -* Allow allocated Active Records to lookup associations. - - Previously, the association cache isn't setup on allocated record objects, so association - lookups will crash. Test frameworks like mocha use allocate to check for stubbable instance - methods, which can trigger an association lookup. - - *Gannon McGibbon* - -* Encryption now supports `support_unencrypted_data: true` being set per-attribute. - - Previously this only worked if `ActiveRecord::Encryption.config.support_unencrypted_data == true`. - Now, if the global config is turned off, you can still opt in for a specific attribute. - - ```ruby - # ActiveRecord::Encryption.config.support_unencrypted_data = true - class User < ActiveRecord::Base - encrypts :name, support_unencrypted_data: false # only supports encrypted data - encrypts :email # supports encrypted or unencrypted data - end - ``` - - ```ruby - # ActiveRecord::Encryption.config.support_unencrypted_data = false - class User < ActiveRecord::Base - encrypts :name, support_unencrypted_data: true # supports encrypted or unencrypted data - encrypts :email # only supports encrypted data - end - ``` - - *Alex Ghiculescu* - -* Model generator no longer needs a database connection to validate column types. - - *Mike Dalessio* - -* Allow signed ID verifiers to be configurable via `Rails.application.message_verifiers` - - Prior to this change, the primary way to configure signed ID verifiers was - to set `signed_id_verifier` on each model class: - - ```ruby - Post.signed_id_verifier = ActiveSupport::MessageVerifier.new(...) - Comment.signed_id_verifier = ActiveSupport::MessageVerifier.new(...) - ``` - - And if the developer did not set `signed_id_verifier`, a verifier would be - instantiated with a secret derived from `secret_key_base` and the following - options: - - ```ruby - { digest: "SHA256", serializer: JSON, url_safe: true } - ``` - - Thus it was cumbersome to rotate configuration for all verifiers. - - This change defines a new Rails config: [`config.active_record.use_legacy_signed_id_verifier`][]. - The default value is `:generate_and_verify`, which preserves the previous - behavior. However, when set to `:verify`, signed ID verifiers will use - configuration from `Rails.application.message_verifiers` (specifically, - `Rails.application.message_verifiers["active_record/signed_id"]`) to - generate and verify signed IDs, but will also verify signed IDs using the - older configuration. - - To avoid complication, the new behavior only applies when `signed_id_verifier_secret` - is not set on a model class or any of its ancestors. Additionally, - `signed_id_verifier_secret` is now deprecated. If you are currently setting - `signed_id_verifier_secret` on a model class, you can set `signed_id_verifier` - instead: - - ```ruby - # BEFORE - Post.signed_id_verifier_secret = "my secret" - - # AFTER - Post.signed_id_verifier = ActiveSupport::MessageVerifier.new("my secret", digest: "SHA256", serializer: JSON, url_safe: true) - ``` - - To ease migration, `signed_id_verifier` has also been changed to behave as a - `class_attribute` (i.e. inheritable), but _only when `signed_id_verifier_secret` - is not set_: - - ```ruby - # BEFORE - ActiveRecord::Base.signed_id_verifier = ActiveSupport::MessageVerifier.new(...) - Post.signed_id_verifier == ActiveRecord::Base.signed_id_verifier # => false - - # AFTER - ActiveRecord::Base.signed_id_verifier = ActiveSupport::MessageVerifier.new(...) - Post.signed_id_verifier == ActiveRecord::Base.signed_id_verifier # => true - - Post.signed_id_verifier_secret = "my secret" # => deprecation warning - Post.signed_id_verifier == ActiveRecord::Base.signed_id_verifier # => false - ``` - - Note, however, that it is recommended to eventually migrate from - model-specific verifiers to a unified configuration managed by - `Rails.application.message_verifiers`. `ActiveSupport::MessageVerifier#rotate` - can facilitate that transition. For example: - - ```ruby - # BEFORE - # Generate and verify signed Post IDs using Post-specific configuration - Post.signed_id_verifier = ActiveSupport::MessageVerifier.new("post secret", ...) - - # AFTER - # Generate and verify signed Post IDs using the unified configuration - Post.signed_id_verifier = Post.signed_id_verifier.dup - # Fall back to Post-specific configuration when verifying signed IDs - Post.signed_id_verifier.rotate("post secret", ...) - ``` - - [`config.active_record.use_legacy_signed_id_verifier`]: https://guides.rubyonrails.org/v8.1/configuring.html#config-active-record-use-legacy-signed-id-verifier - - *Ali Sepehri*, *Jonathan Hefner* - -* Prepend `extra_flags` in postgres' `structure_load` - - When specifying `structure_load_flags` with a postgres adapter, the flags - were appended to the default flags, instead of prepended. - This caused issues with flags not being taken into account by postgres. - - *Alice Loeser* - -* Allow bypassing primary key/constraint addition in `implicit_order_column` - - When specifying multiple columns in an array for `implicit_order_column`, adding - `nil` as the last element will prevent appending the primary key to order - conditions. This allows more precise control of indexes used by - generated queries. It should be noted that this feature does introduce the risk - of API misbehavior if the specified columns are not fully unique. - - *Issy Long* - -* Allow setting the `schema_format` via database configuration. - - ``` - primary: - schema_format: ruby - ``` - - Useful for multi-database setups when apps require different formats per-database. - - *T S Vallender* - -* Support disabling indexes for MySQL v8.0.0+ and MariaDB v10.6.0+ - - MySQL 8.0.0 added an option to disable indexes from being used by the query - optimizer by making them "invisible". This allows the index to still be maintained - and updated but no queries will be permitted to use it. This can be useful for adding - new invisible indexes or making existing indexes invisible before dropping them - to ensure queries are not negatively affected. - See https://dev.mysql.com/blog-archive/mysql-8-0-invisible-indexes/ for more details. - - MariaDB 10.6.0 also added support for this feature by allowing indexes to be "ignored" - in queries. See https://mariadb.com/kb/en/ignored-indexes/ for more details. - - Active Record now supports this option for MySQL 8.0.0+ and MariaDB 10.6.0+ for - index creation and alteration where the new index option `enabled: true/false` can be - passed to column and index methods as below: - - ```ruby - add_index :users, :email, enabled: false - enable_index :users, :email - add_column :users, :dob, :string, index: { enabled: false } - - change_table :users do |t| - t.index :name, enabled: false - t.index :dob - t.disable_index :dob - t.column :username, :string, index: { enabled: false } - t.references :account, index: { enabled: false } - end - - create_table :users do |t| - t.string :name, index: { enabled: false } - t.string :email - t.index :email, enabled: false - end - ``` - - *Merve Taner* - -* Respect `implicit_order_column` in `ActiveRecord::Relation#reverse_order`. - - *Joshua Young* - -* Add column types to `ActiveRecord::Result` for SQLite3. - - *Andrew Kane* - -* Raise `ActiveRecord::ReadOnlyError` when pessimistically locking with a readonly role. - - *Joshua Young* - -* Fix using the `SQLite3Adapter`'s `dbconsole` method outside of a Rails application. - - *Hartley McGuire* - -* Fix migrating multiple databases with `ActiveRecord::PendingMigration` action. - - *Gannon McGibbon* - -* Enable automatically retrying idempotent association queries on connection - errors. - - *Hartley McGuire* - -* Add `allow_retry` to `sql.active_record` instrumentation. - - This enables identifying queries which queries are automatically retryable on connection errors. - - *Hartley McGuire* - -* Better support UPDATE with JOIN for Postgresql and SQLite3 - - Previously when generating update queries with one or more JOIN clauses, - Active Record would use a sub query which would prevent to reference the joined - tables in the `SET` clause, for instance: - - ```ruby - Comment.joins(:post).update_all("title = posts.title") - ``` - - This is now supported as long as the relation doesn't also use a `LIMIT`, `ORDER` or - `GROUP BY` clause. This was supported by the MySQL adapter for a long time. - - *Jean Boussier* - -* Introduce a before-fork hook in `ActiveSupport::Testing::Parallelization` to clear existing - connections, to avoid fork-safety issues with the mysql2 adapter. - - Fixes #41776 - - *Mike Dalessio*, *Donal McBreen* - -* PoolConfig no longer keeps a reference to the connection class. - - Keeping a reference to the class caused subtle issues when combined with reloading in - development. Fixes #54343. - - *Mike Dalessio* - -* Fix SQL notifications sometimes not sent when using async queries. - - ```ruby - Post.async_count - ActiveSupport::Notifications.subscribed(->(*) { "Will never reach here" }) do - Post.count - end - ``` - - In rare circumstances and under the right race condition, Active Support notifications - would no longer be dispatched after using an asynchronous query. - This is now fixed. - - *Edouard Chin* - -* Eliminate queries loading dumped schema cache on Postgres - - Improve resiliency by avoiding needing to open a database connection to load the - type map while defining attribute methods at boot when a schema cache file is - configured on PostgreSQL databases. - - *James Coleman* - -* `ActiveRecord::Coder::JSON` can be instantiated - - Options can now be passed to `ActiveRecord::Coder::JSON` when instantiating the coder. This allows: - ```ruby - serialize :config, coder: ActiveRecord::Coder::JSON.new(symbolize_names: true) - ``` - *matthaigh27* - -* Deprecate using `insert_all`/`upsert_all` with unpersisted records in associations. - - Using these methods on associations containing unpersisted records will now - show a deprecation warning, as the unpersisted records will be lost after - the operation. - - *Nick Schwaderer* - -* Make column name optional for `index_exists?`. - - This aligns well with `remove_index` signature as well, where - index name doesn't need to be derived from the column names. - - *Ali Ismayiliov* - -* Change the payload name of `sql.active_record` notification for eager - loading from "SQL" to "#{model.name} Eager Load". - - *zzak* - -* Enable automatically retrying idempotent `#exists?` queries on connection - errors. - - *Hartley McGuire*, *classidied* - -* Deprecate usage of unsupported methods in conjunction with `update_all`: - - `update_all` will now print a deprecation message if a query includes either `WITH`, - `WITH RECURSIVE` or `DISTINCT` statements. Those were never supported and were ignored - when generating the SQL query. - - An error will be raised in a future Rails release. This behavior will be consistent - with `delete_all` which currently raises an error for unsupported statements. - - *Edouard Chin* - -* The table columns inside `schema.rb` are now sorted alphabetically. - - Previously they'd be sorted by creation order, which can cause merge conflicts when two - branches modify the same table concurrently. - - *John Duff* - -* Introduce versions formatter for the schema dumper. - - It is now possible to override how schema dumper formats versions information inside the - `structure.sql` file. Currently, the versions are simply sorted in the decreasing order. - Within large teams, this can potentially cause many merge conflicts near the top of the list. - - Now, the custom formatter can be provided with a custom sorting logic (e.g. by hash values - of the versions), which can greatly reduce the number of conflicts. - - *fatkodima* - -* Serialized attributes can now be marked as comparable. - - A not rare issue when working with serialized attributes is that the serialized representation of an object - can change over time. Either because you are migrating from one serializer to the other (e.g. YAML to JSON or to msgpack), - or because the serializer used subtly changed its output. - - One example is libyaml that used to have some extra trailing whitespaces, and recently fixed that. - When this sorts of thing happen, you end up with lots of records that report being changed even though - they aren't, which in the best case leads to a lot more writes to the database and in the worst case lead to nasty bugs. - - The solution is to instead compare the deserialized representation of the object, however Active Record - can't assume the deserialized object has a working `==` method. Hence why this new functionality is opt-in. - - ```ruby - serialize :config, type: Hash, coder: JSON, comparable: true - ``` - - *Jean Boussier* - -* Fix MySQL default functions getting dropped when changing a column's nullability. - - *Bastian Bartmann* - -* SQLite extensions can be configured in `config/database.yml`. - - The database configuration option `extensions:` allows an application to load SQLite extensions - when using `sqlite3` >= v2.4.0. The array members may be filesystem paths or the names of - modules that respond to `.to_path`: - - ``` yaml - development: - adapter: sqlite3 - extensions: - - SQLean::UUID # module name responding to `.to_path` - - .sqlpkg/nalgeon/crypto/crypto.so # or a filesystem path - - <%= AppExtensions.location %> # or ruby code returning a path - ``` - - *Mike Dalessio* - -* `ActiveRecord::Middleware::ShardSelector` supports granular database connection switching. - - A new configuration option, `class_name:`, is introduced to - `config.active_record.shard_selector` to allow an application to specify the abstract connection - class to be switched by the shard selection middleware. The default class is - `ActiveRecord::Base`. - - For example, this configuration tells `ShardSelector` to switch shards using - `AnimalsRecord.connected_to`: - - ``` - config.active_record.shard_selector = { class_name: "AnimalsRecord" } - ``` - - *Mike Dalessio* - -* Reset relations after `insert_all`/`upsert_all`. - - Bulk insert/upsert methods will now call `reset` if used on a relation, matching the behavior of `update_all`. - - *Milo Winningham* - -* Use `_N` as a parallel tests databases suffixes - - Peviously, `-N` was used as a suffix. This can cause problems for RDBMSes - which do not support dashes in database names. - - *fatkodima* - -* Remember when a database connection has recently been verified (for - two seconds, by default), to avoid repeated reverifications during a - single request. - - This should recreate a similar rate of verification as in Rails 7.1, - where connections are leased for the duration of a request, and thus - only verified once. - - *Matthew Draper* - -* Allow to reset cache counters for multiple records. - - ``` - Aircraft.reset_counters([1, 2, 3], :wheels_count) - ``` - - It produces much fewer queries compared to the custom implementation using looping over ids. - Previously: `O(ids.size * counters.size)` queries, now: `O(ids.size + counters.size)` queries. - - *fatkodima* - -* Add `affected_rows` to `sql.active_record` Notification. - - *Hartley McGuire* - -* Fix `sum` when performing a grouped calculation. - - `User.group(:friendly).sum` no longer worked. This is fixed. - - *Edouard Chin* - -* Add support for enabling or disabling transactional tests per database. - - A test class can now override the default `use_transactional_tests` setting - for individual databases, which can be useful if some databases need their - current state to be accessible to an external process while tests are running. - - ```ruby - class MostlyTransactionalTest < ActiveSupport::TestCase - self.use_transactional_tests = true - skip_transactional_tests_for_database :shared - end - ``` - - *Matthew Cheetham*, *Morgan Mareve* - -* Cast `query_cache` value when using URL configuration. - - *zzak* - -* NULLS NOT DISTINCT works with UNIQUE CONSTRAINT as well as UNIQUE INDEX. - - *Ryuta Kamizono* - -* `PG::UnableToSend: no connection to the server` is now retryable as a connection-related exception - - *Kazuma Watanabe* - -Please check [8-0-stable](https://github.com/rails/rails/blob/8-0-stable/activerecord/CHANGELOG.md) for previous changes. +Please check [8-1-stable](https://github.com/rails/rails/blob/8-1-stable/activerecord/CHANGELOG.md) for previous changes. diff --git a/activerecord/README.rdoc b/activerecord/README.rdoc index 05c751b3d89ab..0cd5750f69377 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[8.1] + class AddSystemSettings < ActiveRecord::Migration[8.2] def up create_table :system_settings do |t| t.string :name diff --git a/activerecord/lib/active_record/associations.rb b/activerecord/lib/active_record/associations.rb index 6c8c0a4cd3b78..9a840649b9890 100644 --- a/activerecord/lib/active_record/associations.rb +++ b/activerecord/lib/active_record/associations.rb @@ -1843,7 +1843,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[8.1] + # class CreateDevelopersProjectsJoinTable < ActiveRecord::Migration[8.2] # def change # create_join_table :developers, :projects # end diff --git a/activerecord/lib/active_record/gem_version.rb b/activerecord/lib/active_record/gem_version.rb index 9e1c095e7cf8f..082569a54efea 100644 --- a/activerecord/lib/active_record/gem_version.rb +++ b/activerecord/lib/active_record/gem_version.rb @@ -8,9 +8,9 @@ def self.gem_version module VERSION MAJOR = 8 - MINOR = 1 + MINOR = 2 TINY = 0 - PRE = "beta1" + PRE = "alpha" STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".") end diff --git a/activerecord/lib/active_record/migration.rb b/activerecord/lib/active_record/migration.rb index 09980afa673e7..077b570b5f0f9 100644 --- a/activerecord/lib/active_record/migration.rb +++ b/activerecord/lib/active_record/migration.rb @@ -18,7 +18,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[8.1] + # class IrreversibleMigrationExample < ActiveRecord::Migration[8.2] # def change # create_table :distributors do |t| # t.string :zipcode @@ -36,7 +36,7 @@ def initialize(message = nil) # # 1. Define #up and #down methods instead of #change: # - # class ReversibleMigrationExample < ActiveRecord::Migration[8.1] + # class ReversibleMigrationExample < ActiveRecord::Migration[8.2] # def up # create_table :distributors do |t| # t.string :zipcode @@ -61,7 +61,7 @@ def initialize(message = nil) # # 2. Use the #reversible method in #change method: # - # class ReversibleMigrationExample < ActiveRecord::Migration[8.1] + # class ReversibleMigrationExample < ActiveRecord::Migration[8.2] # def change # create_table :distributors do |t| # t.string :zipcode @@ -246,7 +246,7 @@ def initialize # # Example of a simple migration: # - # class AddSsl < ActiveRecord::Migration[8.1] + # class AddSsl < ActiveRecord::Migration[8.2] # def up # add_column :accounts, :ssl_enabled, :boolean, default: true # end @@ -266,7 +266,7 @@ def initialize # # Example of a more complex migration that also needs to initialize data: # - # class AddSystemSettings < ActiveRecord::Migration[8.1] + # class AddSystemSettings < ActiveRecord::Migration[8.2] # def up # create_table :system_settings do |t| # t.string :name @@ -395,7 +395,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[8.1] + # class AddFieldnameToTablename < ActiveRecord::Migration[8.2] # def change # add_column :tablenames, :fieldname, :string # end @@ -421,7 +421,7 @@ def initialize # # Not all migrations change the schema. Some just fix the data: # - # class RemoveEmptyTags < ActiveRecord::Migration[8.1] + # class RemoveEmptyTags < ActiveRecord::Migration[8.2] # def up # Tag.all.each { |tag| tag.destroy if tag.pages.empty? } # end @@ -434,7 +434,7 @@ def initialize # # Others remove columns when they migrate up instead of down: # - # class RemoveUnnecessaryItemAttributes < ActiveRecord::Migration[8.1] + # class RemoveUnnecessaryItemAttributes < ActiveRecord::Migration[8.2] # def up # remove_column :items, :incomplete_items_count # remove_column :items, :completed_items_count @@ -448,7 +448,7 @@ def initialize # # And sometimes you need to do something in SQL not abstracted directly by migrations: # - # class MakeJoinUnique < ActiveRecord::Migration[8.1] + # class MakeJoinUnique < ActiveRecord::Migration[8.2] # def up # execute "ALTER TABLE `pages_linked_pages` ADD UNIQUE `page_id_linked_page_id` (`page_id`,`linked_page_id`)" # end @@ -465,7 +465,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[8.1] + # class AddPeopleSalary < ActiveRecord::Migration[8.2] # def up # add_column :people, :salary, :integer # Person.reset_column_information @@ -527,7 +527,7 @@ def initialize # To define a reversible migration, define the +change+ method in your # migration like this: # - # class TenderloveMigration < ActiveRecord::Migration[8.1] + # class TenderloveMigration < ActiveRecord::Migration[8.2] # def change # create_table(:horses) do |t| # t.column :content, :text @@ -557,7 +557,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[8.1] + # class ChangeEnum < ActiveRecord::Migration[8.2] # disable_ddl_transaction! # # def up @@ -824,7 +824,7 @@ def execution_strategy # and create the table 'apples' on the way up, and the reverse # on the way down. # - # class FixTLMigration < ActiveRecord::Migration[8.1] + # class FixTLMigration < ActiveRecord::Migration[8.2] # def change # revert do # create_table(:horses) do |t| @@ -843,7 +843,7 @@ def execution_strategy # # require_relative "20121212123456_tenderlove_migration" # - # class FixupTLMigration < ActiveRecord::Migration[8.1] + # class FixupTLMigration < ActiveRecord::Migration[8.2] # def change # revert TenderloveMigration # @@ -894,7 +894,7 @@ def down # when the three columns 'first_name', 'last_name' and 'full_name' exist, # even when migrating down: # - # class SplitNameMigration < ActiveRecord::Migration[8.1] + # class SplitNameMigration < ActiveRecord::Migration[8.2] # def change # add_column :users, :first_name, :string # add_column :users, :last_name, :string @@ -922,7 +922,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[8.1] + # class AddPublishedToPosts < ActiveRecord::Migration[8.2] # def change # add_column :posts, :published, :boolean, default: false # up_only do diff --git a/activerecord/lib/active_record/migration/compatibility.rb b/activerecord/lib/active_record/migration/compatibility.rb index f9433b15e9598..2d406738fef1d 100644 --- a/activerecord/lib/active_record/migration/compatibility.rb +++ b/activerecord/lib/active_record/migration/compatibility.rb @@ -21,7 +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: - # 5.2 < 6.0 < 6.1 < 7.0 < 7.1 < 7.2 < 8.0 < 8.1 + # 5.2 < 6.0 < 6.1 < 7.0 < 7.1 < 7.2 < 8.0 < 8.1 < 8.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. @@ -29,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. - V8_1 = Current + V8_2 = Current + + class V8_1 < V8_2 + end class V8_0 < V8_1 module RemoveForeignKeyColumnMatch diff --git a/activerecord/lib/active_record/model_schema.rb b/activerecord/lib/active_record/model_schema.rb index 7f69548fe0602..d92cdf0a8be63 100644 --- a/activerecord/lib/active_record/model_schema.rb +++ b/activerecord/lib/active_record/model_schema.rb @@ -517,7 +517,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[8.1] + # class CreateJobLevels < ActiveRecord::Migration[8.2] # def up # create_table :job_levels do |t| # t.integer :id diff --git a/activestorage/CHANGELOG.md b/activestorage/CHANGELOG.md index 3145ca4feaa97..f520a6e902c95 100644 --- a/activestorage/CHANGELOG.md +++ b/activestorage/CHANGELOG.md @@ -1,90 +1,2 @@ -* Add structured events for Active Storage: - - `active_storage.service_upload` - - `active_storage.service_download` - - `active_storage.service_streaming_download` - - `active_storage.preview` - - `active_storage.service_delete` - - `active_storage.service_delete_prefixed` - - `active_storage.service_exist` - - `active_storage.service_url` - - `active_storage.service_mirror` - *Gannon McGibbon* - -* Allow analyzers and variant transformer to be fully configurable - - ```ruby - # ActiveStorage.analyzers can be set to an empty array: - config.active_storage.analyzers = [] - # => ActiveStorage.analyzers = [] - - # or use custom analyzer: - config.active_storage.analyzers = [ CustomAnalyzer ] - # => ActiveStorage.analyzers = [ CustomAnalyzer ] - ``` - - If no configuration is provided, it will use the default analyzers. - - You can also disable variant processor to remove warnings on startup about missing gems. - - ```ruby - config.active_storage.variant_processor = :disabled - ``` - - *zzak*, *Alexandre Ruban* - -## Rails 8.1.0.beta1 (September 04, 2025) ## - -* Remove deprecated `:azure` storage service. - - *Rafael Mendonça França* - -* Remove unnecessary calls to the GCP metadata server. - - Calling Google::Auth.get_application_default triggers an explicit call to - the metadata server - given it was being called for significant number of - file operations, it can lead to considerable tail latencies and even metadata - server overloads. Instead, it's preferable (and significantly more efficient) - that applications use: - - ```ruby - Google::Apis::RequestOptions.default.authorization = Google::Auth.get_application_default(...) - ``` - - In the cases applications do not set that, the GCP libraries automatically determine credentials. - - This also enables using credentials other than those of the associated GCP - service account like when using impersonation. - - *Alex Coomans* - -* Direct upload progress accounts for server processing time. - - *Jeremy Daer* - -* Delegate `ActiveStorage::Filename#to_str` to `#to_s` - - Supports checking String equality: - - ```ruby - filename = ActiveStorage::Filename.new("file.txt") - filename == "file.txt" # => true - filename in "file.txt" # => true - "file.txt" == filename # => true - ``` - - *Sean Doyle* - -* A Blob will no longer autosave associated Attachment. - - This fixes an issue where a record with an attachment would have - its dirty attributes reset, preventing your `after commit` callbacks - on that record to behave as expected. - - Note that this change doesn't require any changes on your application - and is supposed to be internal. Active Storage Attachment will continue - to be autosaved (through a different relation). - - *Edouard-chin* - -Please check [8-0-stable](https://github.com/rails/rails/blob/8-0-stable/activestorage/CHANGELOG.md) for previous changes. +Please check [8-1-stable](https://github.com/rails/rails/blob/8-1-stable/activestorage/CHANGELOG.md) for previous changes. diff --git a/activestorage/lib/active_storage/gem_version.rb b/activestorage/lib/active_storage/gem_version.rb index 0773f2840b455..08407ada9e7b8 100644 --- a/activestorage/lib/active_storage/gem_version.rb +++ b/activestorage/lib/active_storage/gem_version.rb @@ -8,9 +8,9 @@ def self.gem_version module VERSION MAJOR = 8 - MINOR = 1 + MINOR = 2 TINY = 0 - PRE = "beta1" + PRE = "alpha" STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".") end diff --git a/activestorage/package.json b/activestorage/package.json index 56628bef6e853..575ba8cae6784 100644 --- a/activestorage/package.json +++ b/activestorage/package.json @@ -1,6 +1,6 @@ { "name": "@rails/activestorage", - "version": "8.1.0-beta1", + "version": "8.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", @@ -22,7 +22,7 @@ "spark-md5": "^3.0.1" }, "devDependencies": { - "@eslint/js":"^9.24.0", + "@eslint/js": "^9.24.0", "@rollup/plugin-node-resolve": "^11.0.1", "@rollup/plugin-commonjs": "^19.0.1", "eslint": "^9.24.0", diff --git a/activesupport/CHANGELOG.md b/activesupport/CHANGELOG.md index 103c2b9890a27..db8bb715ca34a 100644 --- a/activesupport/CHANGELOG.md +++ b/activesupport/CHANGELOG.md @@ -1,460 +1,2 @@ -* Remove deprecated passing a Time object to `Time#since`. - *Rafael Mendonça França* - -* Remove deprecated `Benchmark.ms` method. It is now defined in the `benchmark` gem. - - *Rafael Mendonça França* - -* Remove deprecated addition for `Time` instances with `ActiveSupport::TimeWithZone`. - - *Rafael Mendonça França* - -* Remove deprecated support for `to_time` to preserve the system local time. It will now always preserve the receiver - timezone. - - *Rafael Mendonça França* - -* Deprecate `config.active_support.to_time_preserves_timezone`. - - *Rafael Mendonça França* - -* Standardize event name formatting in `assert_event_reported` error messages. - - The event name in failure messages now uses `.inspect` (e.g., `name: "user.created"`) - to match `assert_events_reported` and provide type clarity between strings and symbols. - This only affects tests that assert on the failure message format itself. - - *George Ma* - -* Fix `Enumerable#sole` to return the full tuple instead of just the first element of the tuple. - - *Olivier Bellone* - -* Fix parallel tests hanging when worker processes die abruptly. - - Previously, if a worker process was killed (e.g., OOM killed, `kill -9`) during parallel - test execution, the test suite would hang forever waiting for the dead worker. - - *Joshua Young* - -* Add `config.active_support.escape_js_separators_in_json`. - - Introduce a new framework default to skip escaping LINE SEPARATOR (U+2028) and PARAGRAPH SEPARATOR (U+2029) in JSON. - - Historically these characters were not valid inside JavaScript literal strings but that changed in ECMAScript 2019. - As such it's no longer a concern in modern browsers: https://caniuse.com/mdn-javascript_builtins_json_json_superset. - - *Étienne Barrié*, *Jean Boussier* - -* Fix `NameError` when `class_attribute` is defined on instance singleton classes. - - Previously, calling `class_attribute` on an instance's singleton class would raise - a `NameError` when accessing the attribute through the instance. - - ```ruby - object = MyClass.new - object.singleton_class.class_attribute :foo, default: "bar" - object.foo # previously raised NameError, now returns "bar" - ``` - - *Joshua Young* - -* Introduce `ActiveSupport::Testing::EventReporterAssertions#with_debug_event_reporting` - to enable event reporter debug mode in tests. - - The previous way to enable debug mode is by using `#with_debug` on the - event reporter itself, which is too verbose. This new helper will help - clear up any confusion on how to test debug events. - - *Gannon McGibbon* - -* Add `ActiveSupport::StructuredEventSubscriber` for consuming notifications and - emitting structured event logs. Events may be emitted with the `#emit_event` - or `#emit_debug_event` methods. - - ```ruby - class MyStructuredEventSubscriber < ActiveSupport::StructuredEventSubscriber - def notification(event) - emit_event("my.notification", data: 1) - end - end - ``` - - *Adrianna Chang* - -* `ActiveSupport::FileUpdateChecker` does not depend on `Time.now` to prevent unecessary reloads with time travel test helpers - - *Jan Grodowski* - -## Rails 8.1.0.beta1 (September 04, 2025) ## - -* Add `ActiveSupport::Cache::Store#namespace=` and `#namespace`. - - Can be used as an alternative to `Store#clear` in some situations such as parallel - testing. - - *Nick Schwaderer* - -* Create `parallel_worker_id` helper for running parallel tests. This allows users to - know which worker they are currently running in. - - *Nick Schwaderer* - -* Make the cache of `ActiveSupport::Cache::Strategy::LocalCache::Middleware` updatable. - - If the cache client at `Rails.cache` of a booted application changes, the corresponding - mounted middleware needs to update in order for request-local caches to be setup properly. - Otherwise, redundant cache operations will erroneously hit the datastore. - - *Gannon McGibbon* - -* Add `assert_events_reported` test helper for `ActiveSupport::EventReporter`. - - This new assertion allows testing multiple events in a single block, regardless of order: - - ```ruby - assert_events_reported([ - { name: "user.created", payload: { id: 123 } }, - { name: "email.sent", payload: { to: "user@example.com" } } - ]) do - create_user_and_send_welcome_email - end - ``` - - *George Ma* - -* Add `ActiveSupport::TimeZone#standard_name` method. - - ``` ruby - zone = ActiveSupport::TimeZone['Hawaii'] - # Old way - ActiveSupport::TimeZone::MAPPING[zone.name] - # New way - zone.standard_name # => 'Pacific/Honolulu' - ``` - - *Bogdan Gusiev* - -* Add Structured Event Reporter, accessible via `Rails.event`. - - The Event Reporter provides a unified interface for producing structured events in Rails - applications: - - ```ruby - Rails.event.notify("user.signup", user_id: 123, email: "user@example.com") - ``` - - It supports adding tags to events: - - ```ruby - Rails.event.tagged("graphql") do - # Event includes tags: { graphql: true } - Rails.event.notify("user.signup", user_id: 123, email: "user@example.com") - end - ``` - - As well as context: - ```ruby - # All events will contain context: {request_id: "abc123", shop_id: 456} - Rails.event.set_context(request_id: "abc123", shop_id: 456) - ``` - - Events are emitted to subscribers. Applications register subscribers to - control how events are serialized and emitted. Subscribers must implement - an `#emit` method, which receives the event hash: - - ```ruby - class LogSubscriber - def emit(event) - payload = event[:payload].map { |key, value| "#{key}=#{value}" }.join(" ") - source_location = event[:source_location] - log = "[#{event[:name]}] #{payload} at #{source_location[:filepath]}:#{source_location[:lineno]}" - Rails.logger.info(log) - end - end - ``` - - *Adrianna Chang* - -* Make `ActiveSupport::Logger` `#freeze`-friendly. - - *Joshua Young* - -* Make `ActiveSupport::Gzip.compress` deterministic based on input. - - `ActiveSupport::Gzip.compress` used to include a timestamp in the output, - causing consecutive calls with the same input data to have different output - if called during different seconds. It now always sets the timestamp to `0` - so that the output is identical for any given input. - - *Rob Brackett* - -* Given an array of `Thread::Backtrace::Location` objects, the new method - `ActiveSupport::BacktraceCleaner#clean_locations` returns an array with the - clean ones: - - ```ruby - clean_locations = backtrace_cleaner.clean_locations(caller_locations) - ``` - - Filters and silencers receive strings as usual. However, the `path` - attributes of the locations in the returned array are the original, - unfiltered ones, since locations are immutable. - - *Xavier Noria* - -* Improve `CurrentAttributes` and `ExecutionContext` state managment in test cases. - - Previously these two global state would be entirely cleared out whenever calling - into code that is wrapped by the Rails executor, typically Action Controller or - Active Job helpers: - - ```ruby - test "#index works" do - CurrentUser.id = 42 - get :index - CurrentUser.id == nil - end - ``` - - Now re-entering the executor properly save and restore that state. - - *Jean Boussier* - -* The new method `ActiveSupport::BacktraceCleaner#first_clean_location` - returns the first clean location of the caller's call stack, or `nil`. - Locations are `Thread::Backtrace::Location` objects. Useful when you want to - report the application-level location where something happened as an object. - - *Xavier Noria* - -* FileUpdateChecker and EventedFileUpdateChecker ignore changes in Gem.path now. - - *Ermolaev Andrey*, *zzak* - -* The new method `ActiveSupport::BacktraceCleaner#first_clean_frame` returns - the first clean frame of the caller's backtrace, or `nil`. Useful when you - want to report the application-level frame where something happened as a - string. - - *Xavier Noria* - -* Always clear `CurrentAttributes` instances. - - Previously `CurrentAttributes` instance would be reset at the end of requests. - Meaning its attributes would be re-initialized. - - This is problematic because it assume these objects don't hold any state - other than their declared attribute, which isn't always the case, and - can lead to state leak across request. - - Now `CurrentAttributes` instances are abandoned at the end of a request, - and a new instance is created at the start of the next request. - - *Jean Boussier*, *Janko Marohnić* - -* Add public API for `before_fork_hook` in parallel testing. - - Introduces a public API for calling the before fork hooks implemented by parallel testing. - - ```ruby - parallelize_before_fork do - # perform an action before test processes are forked - end - ``` - - *Eileen M. Uchitelle* - -* Implement ability to skip creating parallel testing databases. - - With parallel testing, Rails will create a database per process. If this isn't - desirable or you would like to implement databases handling on your own, you can - now turn off this default behavior. - - To skip creating a database per process, you can change it via the - `parallelize` method: - - ```ruby - parallelize(workers: 10, parallelize_databases: false) - ``` - - or via the application configuration: - - ```ruby - config.active_support.parallelize_databases = false - ``` - - *Eileen M. Uchitelle* - -* Allow to configure maximum cache key sizes - - When the key exceeds the configured limit (250 bytes by default), it will be truncated and - the digest of the rest of the key appended to it. - - Note that previously `ActiveSupport::Cache::RedisCacheStore` allowed up to 1kb cache keys before - truncation, which is now reduced to 250 bytes. - - ```ruby - config.cache_store = :redis_cache_store, { max_key_size: 64 } - ``` - - *fatkodima* - -* Use `UNLINK` command instead of `DEL` in `ActiveSupport::Cache::RedisCacheStore` for non-blocking deletion. - - *Aron Roh* - -* Add `Cache#read_counter` and `Cache#write_counter` - - ```ruby - Rails.cache.write_counter("foo", 1) - Rails.cache.read_counter("foo") # => 1 - Rails.cache.increment("foo") - Rails.cache.read_counter("foo") # => 2 - ``` - - *Alex Ghiculescu* - -* Introduce ActiveSupport::Testing::ErrorReporterAssertions#capture_error_reports - - Captures all reported errors from within the block that match the given - error class. - - ```ruby - reports = capture_error_reports(IOError) do - Rails.error.report(IOError.new("Oops")) - Rails.error.report(IOError.new("Oh no")) - Rails.error.report(StandardError.new) - end - - assert_equal 2, reports.size - assert_equal "Oops", reports.first.error.message - assert_equal "Oh no", reports.last.error.message - ``` - - *Andrew Novoselac* - -* Introduce ActiveSupport::ErrorReporter#add_middleware - - When reporting an error, the error context middleware will be called with the reported error - and base execution context. The stack may mutate the context hash. The mutated context will - then be passed to error subscribers. Middleware receives the same parameters as `ErrorReporter#report`. - - *Andrew Novoselac*, *Sam Schmidt* - -* Change execution wrapping to report all exceptions, including `Exception`. - - If a more serious error like `SystemStackError` or `NoMemoryError` happens, - the error reporter should be able to report these kinds of exceptions. - - *Gannon McGibbon* - -* `ActiveSupport::Testing::Parallelization.before_fork_hook` allows declaration of callbacks that - are invoked immediately before forking test workers. - - *Mike Dalessio* - -* Allow the `#freeze_time` testing helper to accept a date or time argument. - - ```ruby - Time.current # => Sun, 09 Jul 2024 15:34:49 EST -05:00 - freeze_time Time.current + 1.day - sleep 1 - Time.current # => Mon, 10 Jul 2024 15:34:49 EST -05:00 - ``` - - *Joshua Young* - -* `ActiveSupport::JSON` now accepts options - - It is now possible to pass options to `ActiveSupport::JSON`: - ```ruby - ActiveSupport::JSON.decode('{"key": "value"}', symbolize_names: true) # => { key: "value" } - ``` - - *matthaigh27* - -* `ActiveSupport::Testing::NotificationAssertions`'s `assert_notification` now matches against payload subsets by default. - - Previously the following assertion would fail due to excess key vals in the notification payload. Now with payload subset matching, it will pass. - - ```ruby - assert_notification("post.submitted", title: "Cool Post") do - ActiveSupport::Notifications.instrument("post.submitted", title: "Cool Post", body: "Cool Body") - end - ``` - - Additionally, you can now persist a matched notification for more customized assertions. - - ```ruby - notification = assert_notification("post.submitted", title: "Cool Post") do - ActiveSupport::Notifications.instrument("post.submitted", title: "Cool Post", body: Body.new("Cool Body")) - end - - assert_instance_of(Body, notification.payload[:body]) - ``` - - *Nicholas La Roux* - -* Deprecate `String#mb_chars` and `ActiveSupport::Multibyte::Chars`. - - These APIs are a relic of the Ruby 1.8 days when Ruby strings weren't encoding - aware. There is no legitimate reasons to need these APIs today. - - *Jean Boussier* - -* Deprecate `ActiveSupport::Configurable` - - *Sean Doyle* - -* `nil.to_query("key")` now returns `key`. - - Previously it would return `key=`, preventing round tripping with `Rack::Utils.parse_nested_query`. - - *Erol Fornoles* - -* Avoid wrapping redis in a `ConnectionPool` when using `ActiveSupport::Cache::RedisCacheStore` if the `:redis` - option is already a `ConnectionPool`. - - *Joshua Young* - -* Alter `ERB::Util.tokenize` to return :PLAIN token with full input string when string doesn't contain ERB tags. - - *Martin Emde* - -* Fix a bug in `ERB::Util.tokenize` that causes incorrect tokenization when ERB tags are preceded by multibyte characters. - - *Martin Emde* - -* Add `ActiveSupport::Testing::NotificationAssertions` module to help with testing `ActiveSupport::Notifications`. - - *Nicholas La Roux*, *Yishu See*, *Sean Doyle* - -* `ActiveSupport::CurrentAttributes#attributes` now will return a new hash object on each call. - - Previously, the same hash object was returned each time that method was called. - - *fatkodima* - -* `ActiveSupport::JSON.encode` supports CIDR notation. - - Previously: - - ```ruby - ActiveSupport::JSON.encode(IPAddr.new("172.16.0.0/24")) # => "\"172.16.0.0\"" - ``` - - After this change: - - ```ruby - ActiveSupport::JSON.encode(IPAddr.new("172.16.0.0/24")) # => "\"172.16.0.0/24\"" - ``` - - *Taketo Takashima* - -* Make `ActiveSupport::FileUpdateChecker` faster when checking many file-extensions. - - *Jonathan del Strother* - -Please check [8-0-stable](https://github.com/rails/rails/blob/8-0-stable/activesupport/CHANGELOG.md) for previous changes. +Please check [8-1-stable](https://github.com/rails/rails/blob/8-1-stable/activesupport/CHANGELOG.md) for previous changes. diff --git a/activesupport/lib/active_support/deprecation.rb b/activesupport/lib/active_support/deprecation.rb index e66653d89084f..ba50de3f85082 100644 --- a/activesupport/lib/active_support/deprecation.rb +++ b/activesupport/lib/active_support/deprecation.rb @@ -68,7 +68,7 @@ def self._instance # :nodoc: # and the second is a library name. # # ActiveSupport::Deprecation.new('2.0', 'MyLibrary') - def initialize(deprecation_horizon = "8.2", gem_name = "Rails") + def initialize(deprecation_horizon = "8.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/gem_version.rb b/activesupport/lib/active_support/gem_version.rb index f5e19a8521ac3..f8622f25faeed 100644 --- a/activesupport/lib/active_support/gem_version.rb +++ b/activesupport/lib/active_support/gem_version.rb @@ -8,9 +8,9 @@ def self.gem_version module VERSION MAJOR = 8 - MINOR = 1 + MINOR = 2 TINY = 0 - PRE = "beta1" + PRE = "alpha" STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".") end diff --git a/guides/CHANGELOG.md b/guides/CHANGELOG.md index 6c3b6382b3ce4..116834326b1f1 100644 --- a/guides/CHANGELOG.md +++ b/guides/CHANGELOG.md @@ -1,13 +1,2 @@ -## Rails 8.1.0.beta1 (September 04, 2025) ## -* In the Active Job bug report template set the queue adapter to the - test adapter so that `assert_enqueued_with` can pass. - - *Andrew White* - -* Ensure all bug report templates set `config.secret_key_base` to avoid - generation of `tmp/local_secret.txt` files when running the report template. - - *Andrew White* - -Please check [8-0-stable](https://github.com/rails/rails/blob/8-0-stable/guides/CHANGELOG.md) for previous changes. +Please check [8-1-stable](https://github.com/rails/rails/blob/8-1-stable/guides/CHANGELOG.md) for previous changes. diff --git a/guides/source/8_2_release_notes.md b/guides/source/8_2_release_notes.md new file mode 100644 index 0000000000000..05488f588bb49 --- /dev/null +++ b/guides/source/8_2_release_notes.md @@ -0,0 +1,183 @@ +**DO NOT READ THIS FILE ON GITHUB, GUIDES ARE PUBLISHED ON .** + +Ruby on Rails 8.2 Release Notes +=============================== + +Highlights in Rails 8.2: + +-------------------------------------------------------------------------------- + +Upgrading to Rails 8.2 +---------------------- + +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 8.1 in case you +haven't and make sure your application still runs as expected before attempting +an update to Rails 8.1. 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-8-1-to-rails-8-2) +guide. + +Major Features +-------------- + +Railties +-------- + +Please refer to the [Changelog][railties] for detailed changes. + +### Removals + +### Deprecations + +### Notable changes + +Action Cable +------------ + +Please refer to the [Changelog][action-cable] for detailed changes. + +### Removals + +### Deprecations + +### Notable changes + +Action Pack +----------- + +Please refer to the [Changelog][action-pack] for detailed changes. + +### Removals + +### Deprecations + +### Notable changes + +Action View +----------- + +Please refer to the [Changelog][action-view] for detailed changes. + +### Removals + +### Deprecations + +### Notable changes + +Action Mailer +------------- + +Please refer to the [Changelog][action-mailer] for detailed changes. + +### Removals + +### Deprecations + +### Notable changes + +Active Record +------------- + +Please refer to the [Changelog][active-record] for detailed changes. + +### Removals + +### Deprecations + +### Notable changes + +Active Storage +-------------- + +Please refer to the [Changelog][active-storage] for detailed changes. + +### Removals + +### Deprecations + +### Notable changes + +Active Model +------------ + +Please refer to the [Changelog][active-model] for detailed changes. + +### Removals + +### Deprecations + +### Notable changes + +Active Support +-------------- + +Please refer to the [Changelog][active-support] for detailed changes. + +### Removals + +### Deprecations + +### Notable changes + +Active Job +---------- + +Please refer to the [Changelog][active-job] for detailed changes. + +### Removals + +### Deprecations + +### Notable changes + +Action Text +---------- + +Please refer to the [Changelog][action-text] for detailed changes. + +### Removals + +### Deprecations + +### Notable changes + +Action Mailbox +---------- + +Please refer to the [Changelog][action-mailbox] for detailed changes. + +### Removals + +### Deprecations + +### Notable changes + +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 4fc47c7038cd0..7c1aebea139a5 100644 --- a/guides/source/_welcome.html.erb +++ b/guides/source/_welcome.html.erb @@ -10,7 +10,7 @@

<% else %>

- These are the new guides for Rails 8.1 based on <%= @version %>. + These are the new guides for Rails 8.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 %> diff --git a/guides/source/active_record_basics.md b/guides/source/active_record_basics.md index 81d31958e8b14..5f3e8c4ec1167 100644 --- a/guides/source/active_record_basics.md +++ b/guides/source/active_record_basics.md @@ -213,7 +213,7 @@ and results in this: # Columns `created_at` and `updated_at` are added by `t.timestamps`. # db/migrate/20240220143807_create_books.rb -class CreateBooks < ActiveRecord::Migration[8.1] +class CreateBooks < ActiveRecord::Migration[8.2] def change create_table :books do |t| t.string :title @@ -673,7 +673,7 @@ 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[8.1] +class CreatePublications < ActiveRecord::Migration[8.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 index 8694213690f48..7d80ed272e744 100644 --- a/guides/source/active_record_composite_primary_keys.md +++ b/guides/source/active_record_composite_primary_keys.md @@ -36,7 +36,7 @@ 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[8.1] +class CreateProducts < ActiveRecord::Migration[8.2] def change create_table :products, primary_key: [:store_id, :sku] do |t| t.integer :store_id diff --git a/guides/source/active_record_migrations.md b/guides/source/active_record_migrations.md index 7c7445d265b83..55c21657e73b5 100644 --- a/guides/source/active_record_migrations.md +++ b/guides/source/active_record_migrations.md @@ -42,7 +42,7 @@ of your database. Here's an example of a migration: ```ruby # db/migrate/20240502100843_create_products.rb -class CreateProducts < ActiveRecord::Migration[8.1] +class CreateProducts < ActiveRecord::Migration[8.2] def change create_table :products do |t| t.string :name @@ -62,7 +62,7 @@ These special columns are automatically managed by Active Record if they exist. ```ruby # db/schema.rb -ActiveRecord::Schema[8.1].define(version: 2024_05_02_100843) do +ActiveRecord::Schema[8.2].define(version: 2024_05_02_100843) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -123,7 +123,7 @@ $ bin/rails generate migration AddPartNumberToProducts ```ruby # db/migrate/20240502101659_add_part_number_to_products.rb -class AddPartNumberToProducts < ActiveRecord::Migration[8.1] +class AddPartNumberToProducts < ActiveRecord::Migration[8.2] def change end end @@ -150,7 +150,7 @@ $ bin/rails generate migration CreateProducts name:string part_number:string generates ```ruby -class CreateProducts < ActiveRecord::Migration[8.1] +class CreateProducts < ActiveRecord::Migration[8.2] def change create_table :products do |t| t.string :name @@ -180,7 +180,7 @@ $ bin/rails generate migration AddPartNumberToProducts part_number:string This will generate the following migration: ```ruby -class AddPartNumberToProducts < ActiveRecord::Migration[8.1] +class AddPartNumberToProducts < ActiveRecord::Migration[8.2] def change add_column :products, :part_number, :string end @@ -197,7 +197,7 @@ This will generate the appropriate [`add_column`][] and [`add_index`][] statements: ```ruby -class AddPartNumberToProducts < ActiveRecord::Migration[8.1] +class AddPartNumberToProducts < ActiveRecord::Migration[8.2] def change add_column :products, :part_number, :string add_index :products, :part_number @@ -215,7 +215,7 @@ This will generate a schema migration which adds two additional columns to the `products` table. ```ruby -class AddDetailsToProducts < ActiveRecord::Migration[8.1] +class AddDetailsToProducts < ActiveRecord::Migration[8.2] def change add_column :products, :part_number, :string add_column :products, :price, :decimal @@ -236,7 +236,7 @@ $ bin/rails generate migration RemovePartNumberFromProducts part_number:string This will generate the appropriate [`remove_column`][] statements: ```ruby -class RemovePartNumberFromProducts < ActiveRecord::Migration[8.1] +class RemovePartNumberFromProducts < ActiveRecord::Migration[8.2] def change remove_column :products, :part_number, :string end @@ -265,7 +265,7 @@ $ bin/rails generate migration AddUserRefToProducts user:references generates the following [`add_reference`][] call: ```ruby -class AddUserRefToProducts < ActiveRecord::Migration[8.1] +class AddUserRefToProducts < ActiveRecord::Migration[8.2] def change add_reference :products, :user, null: false, foreign_key: true end @@ -302,7 +302,7 @@ $ bin/rails generate migration CreateJoinTableUserProduct user product will produce the following migration: ```ruby -class CreateJoinTableUserProduct < ActiveRecord::Migration[8.1] +class CreateJoinTableUserProduct < ActiveRecord::Migration[8.2] def change create_join_table :users, :products do |t| # t.index [:user_id, :product_id] @@ -336,7 +336,7 @@ $ bin/rails generate model Product name:string description:text This will create a migration that looks like this: ```ruby -class CreateProducts < ActiveRecord::Migration[8.1] +class CreateProducts < ActiveRecord::Migration[8.2] def change create_table :products do |t| t.string :name @@ -367,7 +367,7 @@ $ bin/rails generate migration AddDetailsToProducts 'price:decimal{5,2}' supplie will produce a migration that looks like this ```ruby -class AddDetailsToProducts < ActiveRecord::Migration[8.1] +class AddDetailsToProducts < ActiveRecord::Migration[8.2] def change add_column :products, :price, :decimal, precision: 5, scale: 2 add_reference :products, :supplier, polymorphic: true @@ -385,7 +385,7 @@ $ bin/rails generate migration AddEmailToUsers email:string! will produce this migration ```ruby -class AddEmailToUsers < ActiveRecord::Migration[8.1] +class AddEmailToUsers < ActiveRecord::Migration[8.2] def change add_column :users, :email, :string, null: false end @@ -458,7 +458,7 @@ you. You can change the name of the column with the `:primary_key` option, like below: ```ruby -class CreateUsers < ActiveRecord::Migration[8.1] +class CreateUsers < ActiveRecord::Migration[8.2] def change create_table :users, primary_key: "user_id" do |t| t.string :username @@ -484,7 +484,7 @@ You can also pass an array to `:primary_key` for a composite primary key. Read more about [composite primary keys](active_record_composite_primary_keys.html). ```ruby -class CreateUsers < ActiveRecord::Migration[8.1] +class CreateUsers < ActiveRecord::Migration[8.2] def change create_table :users, primary_key: [:id, :name] do |t| t.string :name @@ -498,7 +498,7 @@ end If you don't want a primary key at all, you can pass the option `id: false`. ```ruby -class CreateUsers < ActiveRecord::Migration[8.1] +class CreateUsers < ActiveRecord::Migration[8.2] def change create_table :users, id: false do |t| t.string :username @@ -546,7 +546,7 @@ with large databases. Currently only the MySQL and PostgreSQL adapters support comments. ```ruby -class AddDetailsToProducts < ActiveRecord::Migration[8.1] +class AddDetailsToProducts < ActiveRecord::Migration[8.2] def change add_column :products, :price, :decimal, precision: 8, scale: 2, comment: "The price of the product in USD" add_column :products, :stock_quantity, :integer, comment: "The current stock quantity of the product" @@ -819,7 +819,7 @@ 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[8.1] +class CreateProducts < ActiveRecord::Migration[8.2] def change create_table :products, primary_key: [:customer_id, :product_sku] do |t| t.integer :customer_id @@ -840,7 +840,7 @@ If the helpers provided by Active Record aren't enough, you can use the [`execute`][] method to execute SQL commands. For example, ```ruby -class UpdateProductPrices < ActiveRecord::Migration[8.1] +class UpdateProductPrices < ActiveRecord::Migration[8.2] def up execute "UPDATE products SET price = 'free'" end @@ -961,7 +961,7 @@ how to reverse, then you can use `reversible` to specify what to do when running a migration and what else to do when reverting it. ```ruby -class ChangeProductsPrice < ActiveRecord::Migration[8.1] +class ChangeProductsPrice < ActiveRecord::Migration[8.2] def change reversible do |direction| change_table :products do |t| @@ -980,7 +980,7 @@ to an integer when the migration is reverted. Notice the block being passed to Alternatively, you can use `up` and `down` instead of `change`: ```ruby -class ChangeProductsPrice < ActiveRecord::Migration[8.1] +class ChangeProductsPrice < ActiveRecord::Migration[8.2] def up change_table :products do |t| t.change :price, :string @@ -1001,7 +1001,7 @@ ActiveRecord methods. 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[8.1] +class ExampleMigration < ActiveRecord::Migration[8.2] def change create_table :distributors do |t| t.string :zipcode @@ -1052,7 +1052,7 @@ reverse order they were made in the `up` method. The example in the `reversible` section is equivalent to: ```ruby -class ExampleMigration < ActiveRecord::Migration[8.1] +class ExampleMigration < ActiveRecord::Migration[8.2] def up create_table :distributors do |t| t.string :zipcode @@ -1089,7 +1089,7 @@ In such cases, you can raise `ActiveRecord::IrreversibleMigration` in your `down` block. ```ruby -class IrreversibleMigrationExample < ActiveRecord::Migration[8.1] +class IrreversibleMigrationExample < ActiveRecord::Migration[8.2] def up drop_table :example_table end @@ -1111,7 +1111,7 @@ You can use Active Record's ability to rollback migrations using the ```ruby require_relative "20121212123456_example_migration" -class FixupExampleMigration < ActiveRecord::Migration[8.1] +class FixupExampleMigration < ActiveRecord::Migration[8.2] def change revert ExampleMigration @@ -1129,7 +1129,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[8.1] +class DontUseDistributorsViewMigration < ActiveRecord::Migration[8.2] def change revert do # copy-pasted code from ExampleMigration @@ -1254,7 +1254,7 @@ these situations you can turn the automatic transactions off with `disable_ddl_transaction!`: ```ruby -class ChangeEnum < ActiveRecord::Migration[8.1] +class ChangeEnum < ActiveRecord::Migration[8.2] disable_ddl_transaction! def up @@ -1377,7 +1377,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[8.1] +class CreateProducts < ActiveRecord::Migration[8.2] def change suppress_messages do create_table :products do |t| @@ -1516,7 +1516,7 @@ look at this file you'll find that it looks an awful lot like one very big migration: ```ruby -ActiveRecord::Schema[8.1].define(version: 2008_09_06_171750) do +ActiveRecord::Schema[8.2].define(version: 2008_09_06_171750) do create_table "authors", force: true do |t| t.string "name" t.datetime "created_at" @@ -1614,7 +1614,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[8.1] +class AddInitialProducts < ActiveRecord::Migration[8.2] def up 5.times do |i| Product.create(name: "Product ##{i}", description: "A product.") @@ -1749,7 +1749,7 @@ to enable the pgcrypto extension to access the `gen_random_uuid()` function. ``` ```ruby - class CreateAuthors < ActiveRecord::Migration[8.1] + class CreateAuthors < ActiveRecord::Migration[8.2] def change create_table :authors, id: :uuid do |t| t.timestamps diff --git a/guides/source/active_record_postgresql.md b/guides/source/active_record_postgresql.md index 4e74cb82071d9..d67a6ba736ddc 100644 --- a/guides/source/active_record_postgresql.md +++ b/guides/source/active_record_postgresql.md @@ -102,7 +102,7 @@ NOTE: You need to enable the `hstore` extension to use hstore. ```ruby # db/migrate/20131009135255_create_profiles.rb -class CreateProfiles < ActiveRecord::Migration[8.1] +class CreateProfiles < ActiveRecord::Migration[8.2] enable_extension "hstore" unless extension_enabled?("hstore") create_table :profiles do |t| t.hstore "settings" diff --git a/guides/source/association_basics.md b/guides/source/association_basics.md index da28d97d8d25c..eea33ee25d39a 100644 --- a/guides/source/association_basics.md +++ b/guides/source/association_basics.md @@ -44,7 +44,7 @@ Without associations, creating and deleting books for that author would require a tedious and manual process. Here's what that would look like: ```ruby -class CreateAuthors < ActiveRecord::Migration[8.1] +class CreateAuthors < ActiveRecord::Migration[8.2] def change create_table :authors do |t| t.string :name @@ -193,7 +193,7 @@ Rails will look for a class named `Authors` instead of `Author`. The corresponding migration might look like this: ```ruby -class CreateBooks < ActiveRecord::Migration[8.1] +class CreateBooks < ActiveRecord::Migration[8.2] def change create_table :authors do |t| t.string :name @@ -435,7 +435,7 @@ is declared. The corresponding migration might look like this: ```ruby -class CreateSuppliers < ActiveRecord::Migration[8.1] +class CreateSuppliers < ActiveRecord::Migration[8.2] def change create_table :suppliers do |t| t.string :name @@ -653,7 +653,7 @@ model is pluralized when declaring a `has_many` association. The corresponding migration might look like this: ```ruby -class CreateAuthors < ActiveRecord::Migration[8.1] +class CreateAuthors < ActiveRecord::Migration[8.2] def change create_table :authors do |t| t.string :name @@ -988,7 +988,7 @@ Diagram](images/association_basics/has_many_through.png) The corresponding migration might look like this: ```ruby -class CreateAppointments < ActiveRecord::Migration[8.1] +class CreateAppointments < ActiveRecord::Migration[8.2] def change create_table :physicians do |t| t.string :name @@ -1024,7 +1024,7 @@ key](active_record_composite_primary_keys.html) for the join table in the `has_many :through` relationship like below: ```ruby -class CreateAppointments < ActiveRecord::Migration[8.1] +class CreateAppointments < ActiveRecord::Migration[8.2] def change # ... create_table :appointments, primary_key: [:physician_id, :patient_id] do |t| @@ -1138,7 +1138,7 @@ Diagram](images/association_basics/has_one_through.png) The corresponding migration to set up these associations might look like this: ```ruby -class CreateAccountHistories < ActiveRecord::Migration[8.1] +class CreateAccountHistories < ActiveRecord::Migration[8.2] def change create_table :suppliers do |t| t.string :name @@ -1198,7 +1198,7 @@ manage the relationship between the associated records. The corresponding migration might look like this: ```ruby -class CreateAssembliesAndParts < ActiveRecord::Migration[8.1] +class CreateAssembliesAndParts < ActiveRecord::Migration[8.2] def change create_table :assemblies do |t| t.string :name @@ -1483,7 +1483,7 @@ To implement these associations, you'll need to create the corresponding database tables and set up the foreign key. Here's an example migration: ```ruby -class CreateSuppliers < ActiveRecord::Migration[8.1] +class CreateSuppliers < ActiveRecord::Migration[8.2] def change create_table :suppliers do |t| t.string :name @@ -1619,7 +1619,7 @@ foreign key column (`imageable_id`) and a type column (`imageable_type`) in the model: ```ruby -class CreatePictures < ActiveRecord::Migration[8.1] +class CreatePictures < ActiveRecord::Migration[8.2] def change create_table :pictures do |t| t.string :name @@ -1643,7 +1643,7 @@ recommended to use `t.references` or its alias `t.belongs_to` and specify it automatically adds both the foreign key and type columns to the table. ```ruby -class CreatePictures < ActiveRecord::Migration[8.1] +class CreatePictures < ActiveRecord::Migration[8.2] def change create_table :pictures do |t| t.string :name @@ -1716,7 +1716,7 @@ To support this relationship, we need to add a `manager_id` column to the manager). ```ruby -class CreateEmployees < ActiveRecord::Migration[8.1] +class CreateEmployees < ActiveRecord::Migration[8.2] def change create_table :employees do |t| # Add a belongs_to reference to the manager, which is an employee. @@ -2187,7 +2187,7 @@ the books table. For a brand new table, the migration might look something like this: ```ruby -class CreateBooks < ActiveRecord::Migration[8.1] +class CreateBooks < ActiveRecord::Migration[8.2] def change create_table :books do |t| t.datetime :published_at @@ -2201,7 +2201,7 @@ end Whereas for an existing table, it might look like this: ```ruby -class AddAuthorToBooks < ActiveRecord::Migration[8.1] +class AddAuthorToBooks < ActiveRecord::Migration[8.2] def change add_reference :books, :author end @@ -2241,7 +2241,7 @@ You can then fill out the migration and ensure that the table is created without a primary key. ```ruby -class CreateAssembliesPartsJoinTable < ActiveRecord::Migration[8.1] +class CreateAssembliesPartsJoinTable < ActiveRecord::Migration[8.2] def change create_table :assemblies_parts, id: false do |t| t.bigint :assembly_id @@ -2262,7 +2262,7 @@ are you forgot to set `id: false` when creating your migration. For simplicity, you can also use the method `create_join_table`: ```ruby -class CreateAssembliesPartsJoinTable < ActiveRecord::Migration[8.1] +class CreateAssembliesPartsJoinTable < ActiveRecord::Migration[8.2] def change create_join_table :assemblies, :parts do |t| t.index :assembly_id @@ -2282,7 +2282,7 @@ The main difference in schema implementation between creating a join table for `has_many :through` requires an `id`. ```ruby -class CreateAppointments < ActiveRecord::Migration[8.1] +class CreateAppointments < ActiveRecord::Migration[8.2] def change create_table :appointments do |t| t.belongs_to :physician @@ -3162,7 +3162,7 @@ Although the `:counter_cache` option is specified on the model with the `books_count` column to the `Author` model: ```ruby -class AddBooksCountToAuthors < ActiveRecord::Migration[8.1] +class AddBooksCountToAuthors < ActiveRecord::Migration[8.2] def change add_column :authors, :books_count, :integer, default: 0, null: false end diff --git a/guides/source/command_line.md b/guides/source/command_line.md index 327b0a0e74b69..33913ca7f22d8 100644 --- a/guides/source/command_line.md +++ b/guides/source/command_line.md @@ -182,7 +182,7 @@ With no further work, `bin/rails server` will run our new shiny Rails app: $ cd my_app $ bin/rails server => Booting Puma -=> Rails 8.1.0 application starting in development +=> Rails 8.2.0 application starting in development => Run `bin/rails server --help` for more startup options Puma starting in single mode... * Puma version: 6.4.0 (ruby 3.1.3-p185) ("The Eagle of Durango") @@ -415,7 +415,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 8.1.0) +Loading development environment in sandbox (Rails 8.2.0) Any modifications you make will be rolled back on exit irb(main):001:0> ``` @@ -528,7 +528,7 @@ $ bin/rails destroy model Oops ```bash $ bin/rails about About your application's environment -Rails version 8.1.0 +Rails version 8.2.0 Ruby version 3.2.0 (x86_64-linux) RubyGems version 3.3.7 Rack version 3.0.8 diff --git a/guides/source/configuring.md b/guides/source/configuring.md index 73d23ad926546..7ac5a1a98d54d 100644 --- a/guides/source/configuring.md +++ b/guides/source/configuring.md @@ -58,6 +58,8 @@ NOTE: If you need to apply configuration directly to a class, use a [lazy load h Below are the default values associated with each target version. In cases of conflicting values, newer versions take precedence over older versions. +#### Default Values for Target Version 8.2 + #### Default Values for Target Version 8.1 - [`config.action_controller.action_on_path_relative_redirect`](#config-action-controller-action-on-path-relative-redirect): `:raise` diff --git a/guides/source/debugging_rails_applications.md b/guides/source/debugging_rails_applications.md index 65cacb0aa0baa..60296a51da210 100644 --- a/guides/source/debugging_rails_applications.md +++ b/guides/source/debugging_rails_applications.md @@ -395,7 +395,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-8.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-8.2.0.alpha/lib/action_controller/metal/basic_implicit_render.rb:6 # and 72 frames (use `bt' command for all frames) (rdbg) ``` @@ -462,13 +462,13 @@ When used without any options, `backtrace` lists all the frames on the stack: ```ruby =>#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-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-8.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-8.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-8.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-8.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-8.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-8.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-8.1.0.alpha/lib/active_support/callbacks.rb:127 + #2 AbstractController::Base#process_action(method_name="index", args=[]) at ~/.rbenv/versions/3.0.1/lib/ruby/gems/3.0.0/gems/actionpack-8.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-8.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-8.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-8.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-8.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-8.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-8.2.0.alpha/lib/active_support/callbacks.rb:127 ..... and more ``` diff --git a/guides/source/documents.yaml b/guides/source/documents.yaml index 9913750419c38..3e6d9bfde65fd 100644 --- a/guides/source/documents.yaml +++ b/guides/source/documents.yaml @@ -330,11 +330,15 @@ description: > This guide provides steps to be followed when you upgrade your applications to a newer version of Ruby on Rails. + - + name: Version 8.2 - ? + url: 8_2_release_notes.html + description: Release notes for Rails 8.2. + work_in_progress: true - name: Version 8.1 - ? url: 8_1_release_notes.html description: Release notes for Rails 8.1. - work_in_progress: true - name: Version 8.0 - November 2024 url: 8_0_release_notes.html diff --git a/guides/source/getting_started.md b/guides/source/getting_started.md index 22ab274d1c2ba..38ab293681d21 100644 --- a/guides/source/getting_started.md +++ b/guides/source/getting_started.md @@ -73,7 +73,7 @@ TIP: Any commands prefaced with a dollar sign `$` should be run in the terminal. For this project, you will need: * Ruby 3.2 or newer -* Rails 8.1.0 or newer +* Rails 8.2.0 or newer * A code editor Follow the [Install Ruby on Rails Guide](install_ruby_on_rails.html) if you need @@ -85,10 +85,10 @@ printed out: ```bash $ rails --version -Rails 8.1.0 +Rails 8.2.0 ``` -The version shown should be Rails 8.1.0 or higher. +The version shown should be Rails 8.2.0 or higher. ### Creating Your First Rails App @@ -190,7 +190,7 @@ your Rails application: ```bash => Booting Puma -=> Rails 8.1.0 application starting in development +=> Rails 8.2.0 application starting in development => Run `bin/rails server --help` for more startup options Puma starting in single mode... * Puma version: 6.4.3 (ruby 3.3.5-p100) ("The Eagle of Durango") @@ -288,7 +288,7 @@ the migration does. This is located in `db/migrate/_create_products.rb`: ```ruby -class CreateProducts < ActiveRecord::Migration[8.1] +class CreateProducts < ActiveRecord::Migration[8.2] def change create_table :products do |t| t.string :name @@ -354,7 +354,7 @@ $ bin/rails console You will be presented with a prompt like the following: ```irb -Loading development environment (Rails 8.1.0) +Loading development environment (Rails 8.2.0) store(dev)> ``` @@ -363,7 +363,7 @@ printing out the Rails version: ```irb store(dev)> Rails.version -=> "8.1.0" +=> "8.2.0" ``` It works! @@ -1979,7 +1979,7 @@ This will generate a migration file. Open it and add a default value of `0` to ensure `inventory_count` is never `nil`: ```ruby -class AddInventoryCountToProducts < ActiveRecord::Migration[8.1] +class AddInventoryCountToProducts < ActiveRecord::Migration[8.2] def change add_column :products, :inventory_count, :integer, default: 0 end diff --git a/guides/source/getting_started_with_devcontainer.md b/guides/source/getting_started_with_devcontainer.md index c779564e2dd26..cc6fb5737d7bc 100644 --- a/guides/source/getting_started_with_devcontainer.md +++ b/guides/source/getting_started_with_devcontainer.md @@ -114,7 +114,7 @@ You can open the terminal within VS Code to verify that Rails is installed: ```bash $ rails --version -Rails 8.1.0 +Rails 8.2.0 ``` You can now continue with the [Getting Started guide](getting_started.html) and diff --git a/guides/source/layout.html.erb b/guides/source/layout.html.erb index 0a6540b82d4de..a81c80e8a384c 100644 --- a/guides/source/layout.html.erb +++ b/guides/source/layout.html.erb @@ -69,7 +69,7 @@ diff --git a/guides/source/upgrading_ruby_on_rails.md b/guides/source/upgrading_ruby_on_rails.md index b910e2eccf6e9..fcba276bc6922 100644 --- a/guides/source/upgrading_ruby_on_rails.md +++ b/guides/source/upgrading_ruby_on_rails.md @@ -77,6 +77,11 @@ 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 8.1 to Rails 8.2 +------------------------------------- + +For more information on changes made to Rails 8.2 please see the [release notes](8_2_release_notes.html). + Upgrading from Rails 8.0 to Rails 8.1 ------------------------------------- diff --git a/railties/CHANGELOG.md b/railties/CHANGELOG.md index f743bbff307ef..ae5f021f42068 100644 --- a/railties/CHANGELOG.md +++ b/railties/CHANGELOG.md @@ -1,185 +1,2 @@ -* Suggest `bin/rails action_text:install` from Action Dispatch error page - *Sean Doyle* - -* Remove deprecated `STATS_DIRECTORIES`. - - *Rafael Mendonça França* - -* Remove deprecated `bin/rake stats` command. - - *Rafael Mendonça França* - -* Remove deprecated `rails/console/methods.rb` file. - - *Rafael Mendonça França* - -* Don't generate system tests by default. - - Rails scaffold generator will no longer generate system tests by default. To enable this pass `--system-tests=true` or generate them with `bin/rails generate system_test name_of_test`. - - *Eileen M. Uchitelle* - -* Optionally skip bundler-audit. - - Skips adding the `bin/bundler-audit` & `config/bundler-audit.yml` if the gem is not installed when `bin/rails app:update` runs. - - Passes an option to `--skip-bundler-audit` when new apps are generated & adds that same option to the `--minimal` generator flag. - - *Jill Klang* - -* Show engine routes in `/rails/info/routes` as well. - - *Petrik de Heus* - -* Exclude `asset_path` configuration from Kamal `deploy.yml` for API applications. - - API applications don't serve assets, so the `asset_path` configuration in `deploy.yml` - is not needed and can cause 404 errors on in-flight requests. The asset_path is now - only included for regular Rails applications that serve assets. - - *Saiqul Haq* - -* Reverted the incorrect default `config.public_file_server.headers` config. - - If you created a new application using Rails `8.1.0.beta1`, make sure to regenerate - `config/environments/production.rb`, or to manually edit the `config.public_file_server.headers` - configuration to just be: - - ```ruby - # Cache assets for far-future expiry since they are all digest stamped. - config.public_file_server.headers = { "cache-control" => "public, max-age=#{1.year.to_i}" } - ``` - - *Jean Boussier* - - -## Rails 8.1.0.beta1 (September 04, 2025) ## - -* Add command `rails credentials:fetch PATH` to get the value of a credential from the credentials file. - - ```bash - $ bin/rails credentials:fetch kamal_registry.password - ``` - - *Matthew Nguyen*, *Jean Boussier* - -* Generate static BCrypt password digests in fixtures instead of dynamic ERB expressions. - - Previously, fixtures with password digest attributes used `<%= BCrypt::Password.create("secret") %>`, - which regenerated the hash on each test run. Now generates a static hash with a comment - showing how to recreate it. - - *Nate Smith*, *Cassia Scheffer* - -* Broaden the `.gitignore` entry when adding a credentials key to ignore all key files. - - *Greg Molnar* - -* Remove unnecessary `ruby-version` input from `ruby/setup-ruby` - - *TangRufus* - -* Add --reset option to bin/setup which will call db:reset as part of the setup. - - *DHH* - -* Add RuboCop cache restoration to RuboCop job in GitHub Actions workflow templates. - - *Lovro Bikić* - -* Skip generating mailer-related files in authentication generator if the application does - not use ActionMailer - - *Rami Massoud* - -* Introduce `bin/ci` for running your tests, style checks, and security audits locally or in the cloud. - - The specific steps are defined by a new DSL in `config/ci.rb`. - - ```ruby - ActiveSupport::ContinuousIntegration.run do - step "Setup", "bin/setup --skip-server" - step "Style: Ruby", "bin/rubocop" - step "Security: Gem audit", "bin/bundler-audit" - step "Tests: Rails", "bin/rails test test:system" - end - ``` - - Optionally use [gh-signoff](https://github.com/basecamp/gh-signoff) to - set a green PR status - ready for merge. - - *Jeremy Daer*, *DHH* - -* Generate session controller tests when running the authentication generator. - - *Jerome Dalbert* - -* Add bin/bundler-audit and config/bundler-audit.yml for discovering and managing known security problems with app gems. - - *DHH* - -* Rails no longer generates a `bin/bundle` binstub when creating new applications. - - The `bin/bundle` binstub used to help activate the right version of bundler. - This is no longer necessary as this mechanism is now part of Rubygem itself. - - *Edouard Chin* - -* Add a `SessionTestHelper` module with `sign_in_as(user)` and `sign_out` test helpers when - running `rails g authentication`. Simplifies authentication in integration tests. - - *Bijan Rahnema* - -* Rate limit password resets in authentication generator - - This helps mitigate abuse from attackers spamming the password reset form. - - *Chris Oliver* - -* Update `rails new --minimal` option - - Extend the `--minimal` flag to exclude recently added features: - `skip_brakeman`, `skip_ci`, `skip_docker`, `skip_kamal`, `skip_rubocop`, `skip_solid` and `skip_thruster`. - - *eelcoj* - -* Add `application-name` metadata to application layout - - The following metatag will be added to `app/views/layouts/application.html.erb` - - ```html - - ``` - - *Steve Polito* - -* Use `secret_key_base` from ENV or credentials when present locally. - - When ENV["SECRET_KEY_BASE"] or - `Rails.application.credentials.secret_key_base` is set for test or - development, it is used for the `Rails.config.secret_key_base`, - instead of generating a `tmp/local_secret.txt` file. - - *Petrik de Heus* - -* Introduce `RAILS_MASTER_KEY` placeholder in generated ci.yml files - - *Steve Polito* - -* Colorize the Rails console prompt even on non standard environments. - - *Lorenzo Zabot* - -* Don't enable YJIT in development and test environments - - Development and test environments tend to reload code and redefine methods (e.g. mocking), - hence YJIT isn't generally faster in these environments. - - *Ali Ismayilov*, *Jean Boussier* - -* Only include PermissionsPolicy::Middleware if policy is configured. - - *Petrik de Heus* - -Please check [8-0-stable](https://github.com/rails/rails/blob/8-0-stable/railties/CHANGELOG.md) for previous changes. +Please check [8-1-stable](https://github.com/rails/rails/blob/8-1-stable/railties/CHANGELOG.md) for previous changes. diff --git a/railties/lib/rails/application/configuration.rb b/railties/lib/rails/application/configuration.rb index 285ba0aa63170..854ba0b54bade 100644 --- a/railties/lib/rails/application/configuration.rb +++ b/railties/lib/rails/application/configuration.rb @@ -368,6 +368,8 @@ def load_defaults(target_version) if respond_to?(:action_view) action_view.remove_hidden_field_autocomplete = true end + when "8.2" + load_defaults "8.1" else raise "Unknown version #{target_version.to_s.inspect}" end diff --git a/railties/lib/rails/gem_version.rb b/railties/lib/rails/gem_version.rb index d711489d0f79b..9fed1e48d45c3 100644 --- a/railties/lib/rails/gem_version.rb +++ b/railties/lib/rails/gem_version.rb @@ -8,9 +8,9 @@ def self.gem_version module VERSION MAJOR = 8 - MINOR = 1 + MINOR = 2 TINY = 0 - PRE = "beta1" + PRE = "alpha" STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".") end diff --git a/railties/lib/rails/generators/rails/app/templates/config/initializers/new_framework_defaults_8_1.rb.tt b/railties/lib/rails/generators/rails/app/templates/config/initializers/new_framework_defaults_8_1.rb.tt deleted file mode 100644 index e0e60931b990b..0000000000000 --- a/railties/lib/rails/generators/rails/app/templates/config/initializers/new_framework_defaults_8_1.rb.tt +++ /dev/null @@ -1,93 +0,0 @@ -# Be sure to restart your server when you modify this file. -# -# This file eases your Rails 8.1 framework defaults upgrade. -# -# Uncomment each configuration one by one to switch to the new default. -# Once your application is ready to run with all new defaults, you can remove -# this file and set the `config.load_defaults` to `8.1`. -# -# Read the Guide for Upgrading Ruby on Rails for more info on each option. -# https://guides.rubyonrails.org/upgrading_ruby_on_rails.html - -### -# Skips escaping HTML entities and line separators. When set to `false`, the -# JSON renderer no longer escapes these to improve performance. -# -# Example: -# class PostsController < ApplicationController -# def index -# render json: { key: "\u2028\u2029<>&" } -# end -# end -# -# Renders `{"key":"\u2028\u2029\u003c\u003e\u0026"}` with the previous default, but `{"key":"

<>&"}` with the config -# set to `false`. -# -# Applications that want to keep the escaping behavior can set the config to `true`. -#++ -# Rails.configuration.action_controller.escape_json_responses = false - -### -# Skips escaping LINE SEPARATOR (U+2028) and PARAGRAPH SEPARATOR (U+2029) in JSON. -# -# Historically these characters were not valid inside JavaScript literal strings but that changed in ECMAScript 2019. -# As such it's no longer a concern in modern browsers: https://caniuse.com/mdn-javascript_builtins_json_json_superset. -#++ -# Rails.configuration.active_support.escape_js_separators_in_json = false - -### -# Raises an error when order dependent finder methods (e.g. `#first`, `#second`) are called without `order` values -# on the relation, and the model does not have any order columns (`implicit_order_column`, `query_constraints`, or -# `primary_key`) to fall back on. -# -# The current behavior of not raising an error has been deprecated, and this configuration option will be removed in -# Rails 8.2. -#++ -# Rails.configuration.active_record.raise_on_missing_required_finder_order_columns = true - -### -# Controls how Rails handles path relative URL redirects. -# When set to `:raise`, Rails will raise an `ActionController::Redirecting::UnsafeRedirectError` -# for relative URLs without a leading slash, which can help prevent open redirect vulnerabilities. -# -# Example: -# redirect_to "example.com" # Raises UnsafeRedirectError -# redirect_to "@attacker.com" # Raises UnsafeRedirectError -# redirect_to "/safe/path" # Works correctly -# -# Applications that want to allow these redirects can set the config to `:log` (previous default) -# to only log warnings, or `:notify` to send ActiveSupport notifications. -#++ -# Rails.configuration.action_controller.action_on_path_relative_redirect = :raise - -### -# Controls how Rails handles open redirect vulnerabilities. -# When set to `:raise`, Rails will raise an `ActionController::Redirecting::UnsafeRedirectError` -# for redirects to external hosts, which helps prevent open redirect attacks. -# -# This configuration replaces the deprecated `raise_on_open_redirects` setting, providing -# the ability for large codebases to safely turn on the protection (after monitoring it with :log/:notifications) -# -# Example: -# redirect_to params[:redirect_url] # May raise UnsafeRedirectError if URL is external -# redirect_to "http://evil.com" # Raises UnsafeRedirectError -# redirect_to "/safe/path" # Works correctly (internal URL) -# redirect_to "http://evil.com", allow_other_host: true # Works (explicitly allowed) -# -# Applications that want to allow these redirects can set the config to `:log` (previous default) -# to only log warnings, or `:notify` to send ActiveSupport notifications for monitoring. -# -#++ -# Rails.configuration.action_controller.action_on_open_redirect = :raise -### -# Use a Ruby parser to track dependencies between Action View templates -#++ -# Rails.configuration.action_view.render_tracker = :ruby - -### -# When enabled, hidden inputs generated by `form_tag`, `token_tag`, `method_tag`, and the hidden parameter fields -# included in `button_to` forms will omit the `autocomplete="off"` attribute. -# -# Applications that want to keep generating the `autocomplete` attribute for those tags can set it to `false`. -#++ -# Rails.configuration.action_view.remove_hidden_field_autocomplete = true diff --git a/railties/lib/rails/generators/rails/app/templates/config/initializers/new_framework_defaults_8_2.rb.tt b/railties/lib/rails/generators/rails/app/templates/config/initializers/new_framework_defaults_8_2.rb.tt new file mode 100644 index 0000000000000..564d0096915a8 --- /dev/null +++ b/railties/lib/rails/generators/rails/app/templates/config/initializers/new_framework_defaults_8_2.rb.tt @@ -0,0 +1,10 @@ +# Be sure to restart your server when you modify this file. +# +# This file eases your Rails 8.2 framework defaults upgrade. +# +# Uncomment each configuration one by one to switch to the new default. +# Once your application is ready to run with all new defaults, you can remove +# this file and set the `config.load_defaults` to `8.2`. +# +# Read the Guide for Upgrading Ruby on Rails for more info on each option. +# https://guides.rubyonrails.org/upgrading_ruby_on_rails.html diff --git a/yarn.lock b/yarn.lock index 6c8a093023581..4853a66b3078b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3,76 +3,69 @@ "@babel/code-frame@^7.10.4": - version "7.25.7" - resolved "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.25.7.tgz" - integrity sha512-0xZJFNE5XMpENsgfHYTw8FbX4kv53mFLn2i3XPoq69LyhYSCBJtitaHx9QnsVTrsogI4Z3+HtEfZ2/GFPOtf5g== + version "7.22.5" + resolved "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.5.tgz" + integrity sha512-Xmwn266vad+6DAqEB2A6V/CcZVp62BbwVmcOJc2RPuwih1kw02TjQvWVWlcKGbBPd+8/0V5DEkOcizRGYsspYQ== dependencies: - "@babel/highlight" "^7.25.7" - picocolors "^1.0.0" + "@babel/highlight" "^7.22.5" -"@babel/helper-validator-identifier@^7.25.7": - version "7.25.7" - resolved "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.7.tgz" - integrity sha512-AM6TzwYqGChO45oiuPqwL2t20/HdMC1rTPAesnBCgPCSF1x3oN9MVUwQV2iyz4xqWrctwK5RNC8LV22kaQCNYg== +"@babel/helper-validator-identifier@^7.22.5": + version "7.22.5" + resolved "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.5.tgz" + integrity sha512-aJXu+6lErq8ltp+JhkJUfk1MTGyuA4v7f3pA+BJ5HLfNC6nAQ0Cpi9uOquUj8Hehg0aUiHzWQbOVJGao6ztBAQ== -"@babel/highlight@^7.25.7": - version "7.25.7" - resolved "https://registry.npmjs.org/@babel/highlight/-/highlight-7.25.7.tgz" - integrity sha512-iYyACpW3iW8Fw+ZybQK+drQre+ns/tKpXbNESfrhNnPLIklLbXr7MYJ6gPEd0iETGLOK+SxMjVvKb/ffmk+FEw== +"@babel/highlight@^7.22.5": + version "7.22.5" + resolved "https://registry.npmjs.org/@babel/highlight/-/highlight-7.22.5.tgz" + integrity sha512-BSKlD1hgnedS5XRnGOljZawtag7H1yPfQp0tdNJCHoH6AZ+Pcm9VvkrK59/Yy593Ypg0zMxH2BxD1VPYUQ7UIw== dependencies: - "@babel/helper-validator-identifier" "^7.25.7" - chalk "^2.4.2" + "@babel/helper-validator-identifier" "^7.22.5" + chalk "^2.0.0" js-tokens "^4.0.0" - picocolors "^1.0.0" "@colors/colors@1.5.0": version "1.5.0" resolved "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz" integrity sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ== -"@eslint-community/eslint-utils@^4.2.0": - version "4.4.0" - resolved "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz" - integrity sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA== +"@eslint-community/eslint-utils@^4.8.0": + version "4.9.0" + resolved "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz" + integrity sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g== dependencies: - eslint-visitor-keys "^3.3.0" + eslint-visitor-keys "^3.4.3" "@eslint-community/regexpp@^4.12.1": version "4.12.1" - resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.1.tgz#cfc6cffe39df390a3841cde2abccf92eaa7ae0e0" + resolved "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz" integrity sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ== -"@eslint/config-array@^0.20.0": - version "0.20.1" - resolved "https://registry.yarnpkg.com/@eslint/config-array/-/config-array-0.20.1.tgz#454f89be82b0e5b1ae872c154c7e2f3dd42c3979" - integrity sha512-OL0RJzC/CBzli0DrrR31qzj6d6i6Mm3HByuhflhl4LOBiWxN+3i6/t/ZQQNii4tjksXi8r2CRW1wMpWA2ULUEw== +"@eslint/config-array@^0.21.0": + version "0.21.0" + resolved "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz" + integrity sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ== dependencies: "@eslint/object-schema" "^2.1.6" debug "^4.3.1" minimatch "^3.1.2" -"@eslint/config-helpers@^0.2.0": - version "0.2.3" - resolved "https://registry.yarnpkg.com/@eslint/config-helpers/-/config-helpers-0.2.3.tgz#39d6da64ed05d7662659aa7035b54cd55a9f3672" - integrity sha512-u180qk2Um1le4yf0ruXH3PYFeEZeYC3p/4wCTKrr2U1CmGdzGi3KtY0nuPDH48UJxlKCC5RDzbcbh4X0XlqgHg== - -"@eslint/core@^0.12.0": - version "0.12.0" - resolved "https://registry.yarnpkg.com/@eslint/core/-/core-0.12.0.tgz#5f960c3d57728be9f6c65bd84aa6aa613078798e" - integrity sha512-cmrR6pytBuSMTaBweKoGMwu3EiHiEC+DoyupPmlZ0HxBJBtIxwe+j/E4XPIKNx+Q74c8lXKPwYawBf5glsTkHg== +"@eslint/config-helpers@^0.4.0": + version "0.4.0" + resolved "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.0.tgz" + integrity sha512-WUFvV4WoIwW8Bv0KeKCIIEgdSiFOsulyN0xrMu+7z43q/hkOLXjvb5u7UC9jDxvRzcrbEmuZBX5yJZz1741jog== dependencies: - "@types/json-schema" "^7.0.15" + "@eslint/core" "^0.16.0" -"@eslint/core@^0.13.0": - version "0.13.0" - resolved "https://registry.yarnpkg.com/@eslint/core/-/core-0.13.0.tgz#bf02f209846d3bf996f9e8009db62df2739b458c" - integrity sha512-yfkgDw1KR66rkT5A8ci4irzDysN7FRpq3ttJolR88OqQikAWqwA8j5VZyas+vjyBNFIJ7MfybJ9plMILI2UrCw== +"@eslint/core@^0.16.0": + version "0.16.0" + resolved "https://registry.npmjs.org/@eslint/core/-/core-0.16.0.tgz" + integrity sha512-nmC8/totwobIiFcGkDza3GIKfAw1+hLiYVrh3I1nIomQ8PEr5cxg34jnkmGawul/ep52wGRAcyeDCNtWKSOj4Q== dependencies: "@types/json-schema" "^7.0.15" "@eslint/eslintrc@^3.3.1": version "3.3.1" - resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-3.3.1.tgz#e55f7f1dd400600dd066dbba349c4c0bac916964" + resolved "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz" integrity sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ== dependencies: ajv "^6.12.4" @@ -85,32 +78,32 @@ minimatch "^3.1.2" strip-json-comments "^3.1.1" -"@eslint/js@9.24.0", "@eslint/js@^9.24.0": - version "9.24.0" - resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.24.0.tgz#685277980bb7bf84ecc8e4e133ccdda7545a691e" - integrity sha512-uIY/y3z0uvOGX8cp1C2fiC4+ZmBhp6yZWkojtHL1YEMnRt1Y63HB9TM17proGEmeG7HeUY+UP36F0aknKYTpYA== +"@eslint/js@9.37.0", "@eslint/js@^9.24.0": + version "9.37.0" + resolved "https://registry.npmjs.org/@eslint/js/-/js-9.37.0.tgz" + integrity sha512-jaS+NJ+hximswBG6pjNX0uEJZkrT0zwpVi3BA3vX22aFGjJjmgSTSmPpZCRKmoBL5VY/M6p0xsSJx7rk7sy5gg== "@eslint/object-schema@^2.1.6": version "2.1.6" - resolved "https://registry.yarnpkg.com/@eslint/object-schema/-/object-schema-2.1.6.tgz#58369ab5b5b3ca117880c0f6c0b0f32f6950f24f" + resolved "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz" integrity sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA== -"@eslint/plugin-kit@^0.2.7": - version "0.2.8" - resolved "https://registry.yarnpkg.com/@eslint/plugin-kit/-/plugin-kit-0.2.8.tgz#47488d8f8171b5d4613e833313f3ce708e3525f8" - integrity sha512-ZAoA40rNMPwSm+AeHpCq8STiNAwzWLJuP8Xv4CHIc9wv/PSuExjMrmjfYNj682vW0OOiZ1HKxzvjQr9XZIisQA== +"@eslint/plugin-kit@^0.4.0": + version "0.4.0" + resolved "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.0.tgz" + integrity sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A== dependencies: - "@eslint/core" "^0.13.0" + "@eslint/core" "^0.16.0" levn "^0.4.1" "@humanfs/core@^0.19.1": version "0.19.1" - resolved "https://registry.yarnpkg.com/@humanfs/core/-/core-0.19.1.tgz#17c55ca7d426733fe3c561906b8173c336b40a77" + resolved "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz" integrity sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA== "@humanfs/node@^0.16.6": version "0.16.7" - resolved "https://registry.yarnpkg.com/@humanfs/node/-/node-0.16.7.tgz#822cb7b3a12c5a240a24f621b5a2413e27a45f26" + resolved "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz" integrity sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ== dependencies: "@humanfs/core" "^0.19.1" @@ -123,48 +116,20 @@ "@humanwhocodes/retry@^0.4.0", "@humanwhocodes/retry@^0.4.2": version "0.4.3" - resolved "https://registry.yarnpkg.com/@humanwhocodes/retry/-/retry-0.4.3.tgz#c2b9d2e374ee62c586d3adbea87199b1d7a7a6ba" + resolved "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz" integrity sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ== -"@jridgewell/gen-mapping@^0.3.5": - version "0.3.5" - resolved "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz" - integrity sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg== - dependencies: - "@jridgewell/set-array" "^1.2.1" - "@jridgewell/sourcemap-codec" "^1.4.10" - "@jridgewell/trace-mapping" "^0.3.24" - -"@jridgewell/resolve-uri@^3.1.0": - version "3.1.2" - resolved "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz" - integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw== - -"@jridgewell/set-array@^1.2.1": - version "1.2.1" - resolved "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz" - integrity sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A== - "@jridgewell/source-map@^0.3.3": - version "0.3.6" - resolved "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz" - integrity sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ== - dependencies: - "@jridgewell/gen-mapping" "^0.3.5" - "@jridgewell/trace-mapping" "^0.3.25" - -"@jridgewell/sourcemap-codec@^1.4.10", "@jridgewell/sourcemap-codec@^1.4.14": - version "1.5.0" - resolved "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz" - integrity sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ== + version "0.3.4" + resolved "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.4.tgz" + integrity sha512-KE/SxsDqNs3rrWwFHcRh15ZLVFrI0YoZtgAdIyIq9k5hUNmiWRXXThPomIxHuL20sLdgzbDFyvkUMna14bvtrw== -"@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25": - version "0.3.25" - resolved "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz" - integrity sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ== +"@rails/activestorage@>= 8.1.0-alpha": + version "8.1.0-beta1" + resolved "https://registry.npmjs.org/@rails/activestorage/-/activestorage-8.1.0-beta1.tgz" + integrity sha512-YFA0CpYbQ+rjenounEYgm4KQTqcF6mZGviiz1ovJ5G/Zpxj7kWi0cGaVss1vNXeQgoZY0evDv8J7/cNWvBG/gg== dependencies: - "@jridgewell/resolve-uri" "^3.1.0" - "@jridgewell/sourcemap-codec" "^1.4.14" + spark-md5 "^3.0.1" "@rollup/plugin-commonjs@^19.0.1": version "19.0.2" @@ -202,7 +167,7 @@ "@rtsao/scc@^1.1.0": version "1.1.0" - resolved "https://registry.yarnpkg.com/@rtsao/scc/-/scc-1.1.0.tgz#927dd2fae9bc3361403ac2c7a00c32ddce9ad7e8" + resolved "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz" integrity sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g== "@socket.io/component-emitter@~3.1.0": @@ -229,12 +194,12 @@ "@types/estree@^1.0.6": version "1.0.8" - resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e" + resolved "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz" integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== "@types/json-schema@^7.0.15": version "7.0.15" - resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" + resolved "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz" integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== "@types/json5@^0.0.29": @@ -243,11 +208,9 @@ integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ== "@types/node@*", "@types/node@>=10.0.0": - version "22.7.6" - resolved "https://registry.npmjs.org/@types/node/-/node-22.7.6.tgz" - integrity sha512-/d7Rnj0/ExXDMcioS78/kf1lMzYk4BZV8MZGTBKzTGZ6/406ukkbYlIsZmMPhcR5KlkunDHQLrtAVmSq7r+mSw== - dependencies: - undici-types "~6.19.2" + version "20.3.2" + resolved "https://registry.npmjs.org/@types/node/-/node-20.3.2.tgz" + integrity sha512-vOBLVQeCQfIcF/2Y7eKFTqrMnizK5lRNQ7ykML/5RuwVXVWxYkgwS7xbt4B6fKCUPgbSL5FSsjHQpaGQP/dQmw== "@types/resolve@1.17.1": version "1.17.1" @@ -269,16 +232,11 @@ acorn-jsx@^5.3.2: resolved "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz" integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== -acorn@^8.15.0: +acorn@^8.15.0, acorn@^8.8.2: version "8.15.0" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.15.0.tgz#a360898bc415edaac46c8241f6383975b930b816" + resolved "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz" integrity sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg== -acorn@^8.8.2: - version "8.12.1" - resolved "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz" - integrity sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg== - adm-zip@~0.4.3: version "0.4.16" resolved "https://registry.npmjs.org/adm-zip/-/adm-zip-0.4.16.tgz" @@ -369,25 +327,17 @@ argparse@^2.0.1: resolved "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz" integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== -array-buffer-byte-length@^1.0.1: - version "1.0.1" - resolved "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz" - integrity sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg== - dependencies: - call-bind "^1.0.5" - is-array-buffer "^3.0.4" - -array-buffer-byte-length@^1.0.2: +array-buffer-byte-length@^1.0.1, array-buffer-byte-length@^1.0.2: version "1.0.2" - resolved "https://registry.yarnpkg.com/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz#384d12a37295aec3769ab022ad323a18a51ccf8b" + resolved "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz" integrity sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw== dependencies: call-bound "^1.0.3" is-array-buffer "^3.0.5" -array-includes@^3.1.8: +array-includes@^3.1.9: version "3.1.9" - resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.1.9.tgz#1f0ccaa08e90cdbc3eb433210f903ad0f17c3f3a" + resolved "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz" integrity sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ== dependencies: call-bind "^1.0.8" @@ -399,9 +349,9 @@ array-includes@^3.1.8: is-string "^1.1.1" math-intrinsics "^1.1.0" -array.prototype.findlastindex@^1.2.5: +array.prototype.findlastindex@^1.2.6: version "1.2.6" - resolved "https://registry.yarnpkg.com/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz#cfa1065c81dcb64e34557c9b81d012f6a421c564" + resolved "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz" integrity sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ== dependencies: call-bind "^1.0.8" @@ -412,43 +362,29 @@ array.prototype.findlastindex@^1.2.5: es-object-atoms "^1.1.1" es-shim-unscopables "^1.1.0" -array.prototype.flat@^1.3.2: - version "1.3.2" - resolved "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.2.tgz" - integrity sha512-djYB+Zx2vLewY8RWlNCUdHjDXs2XOgm602S9E7P/UpHgfeHL00cRiIF+IN/G/aUJ7kGPb6yO/ErDI5V2s8iycA== - dependencies: - call-bind "^1.0.2" - define-properties "^1.2.0" - es-abstract "^1.22.1" - es-shim-unscopables "^1.0.0" - -array.prototype.flatmap@^1.3.2: - version "1.3.2" - resolved "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.2.tgz" - integrity sha512-Ewyx0c9PmpcsByhSW4r+9zDU7sGjFc86qf/kKtuSCRdhfbk0SNLLkaT5qvcHnRGgc5NP/ly/y+qkXkqONX54CQ== +array.prototype.flat@^1.3.3: + version "1.3.3" + resolved "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz" + integrity sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg== dependencies: - call-bind "^1.0.2" - define-properties "^1.2.0" - es-abstract "^1.22.1" - es-shim-unscopables "^1.0.0" + call-bind "^1.0.8" + define-properties "^1.2.1" + es-abstract "^1.23.5" + es-shim-unscopables "^1.0.2" -arraybuffer.prototype.slice@^1.0.3: - version "1.0.3" - resolved "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.3.tgz" - integrity sha512-bMxMKAjg13EBSVscxTaYA4mRc5t1UAXa2kXiGTNfZ079HIWXEkKmkgFrh/nJqamaLSrXO5H4WFFkPEaLJWbs3A== +array.prototype.flatmap@^1.3.3: + version "1.3.3" + resolved "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz" + integrity sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg== dependencies: - array-buffer-byte-length "^1.0.1" - call-bind "^1.0.5" + call-bind "^1.0.8" define-properties "^1.2.1" - es-abstract "^1.22.3" - es-errors "^1.2.1" - get-intrinsic "^1.2.3" - is-array-buffer "^3.0.4" - is-shared-array-buffer "^1.0.2" + es-abstract "^1.23.5" + es-shim-unscopables "^1.0.2" arraybuffer.prototype.slice@^1.0.4: version "1.0.4" - resolved "https://registry.yarnpkg.com/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz#9d760d84dbdd06d0cbf92c8849615a1a7ab3183c" + resolved "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz" integrity sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ== dependencies: array-buffer-byte-length "^1.0.1" @@ -473,7 +409,7 @@ assert-plus@1.0.0, assert-plus@^1.0.0: async-function@^1.0.0: version "1.0.0" - resolved "https://registry.yarnpkg.com/async-function/-/async-function-1.0.0.tgz#509c9fca60eaf85034c6829838188e4e4c8ffb2b" + resolved "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz" integrity sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA== async@^2.0.0, async@^2.1.2, async@^2.6.3: @@ -604,26 +540,15 @@ bytes@3.1.2: call-bind-apply-helpers@^1.0.0, call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2: version "1.0.2" - resolved "https://registry.yarnpkg.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz#4b5428c222be985d79c3d82657479dbe0b59b2d6" + resolved "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz" integrity sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ== dependencies: es-errors "^1.3.0" function-bind "^1.1.2" -call-bind@^1.0.2, call-bind@^1.0.5, call-bind@^1.0.6, call-bind@^1.0.7: - version "1.0.7" - resolved "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz" - integrity sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w== - dependencies: - es-define-property "^1.0.0" - es-errors "^1.3.0" - function-bind "^1.1.2" - get-intrinsic "^1.2.4" - set-function-length "^1.2.1" - -call-bind@^1.0.8: +call-bind@^1.0.7, call-bind@^1.0.8: version "1.0.8" - resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.8.tgz#0736a9660f537e3388826f440d5ec45f744eaa4c" + resolved "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz" integrity sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww== dependencies: call-bind-apply-helpers "^1.0.0" @@ -633,7 +558,7 @@ call-bind@^1.0.8: call-bound@^1.0.2, call-bound@^1.0.3, call-bound@^1.0.4: version "1.0.4" - resolved "https://registry.yarnpkg.com/call-bound/-/call-bound-1.0.4.tgz#238de935d2a2a692928c538c7ccfa91067fd062a" + resolved "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz" integrity sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg== dependencies: call-bind-apply-helpers "^1.0.2" @@ -649,7 +574,7 @@ caseless@~0.12.0: resolved "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz" integrity sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw== -chalk@^2.4.2: +chalk@^2.0.0: version "2.4.2" resolved "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz" integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== @@ -766,10 +691,10 @@ content-type@~1.0.5: resolved "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz" integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== -cookie@~0.7.2: - version "0.7.2" - resolved "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz" - integrity sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w== +cookie@~0.4.1: + version "0.4.2" + resolved "https://registry.npmjs.org/cookie/-/cookie-0.4.2.tgz" + integrity sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA== core-util-is@1.0.2: version "1.0.2" @@ -806,7 +731,7 @@ crc@^3.4.4: cross-spawn@^7.0.6: version "7.0.6" - resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" + resolved "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz" integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== dependencies: path-key "^3.1.0" @@ -825,54 +750,27 @@ dashdash@^1.12.0: dependencies: assert-plus "^1.0.0" -data-view-buffer@^1.0.1: - version "1.0.1" - resolved "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.1.tgz" - integrity sha512-0lht7OugA5x3iJLOWFhWK/5ehONdprk0ISXqVFn/NFrDu+cuc8iADFrGQz5BnRK7LLU3JmkbXSxaqX+/mXYtUA== - dependencies: - call-bind "^1.0.6" - es-errors "^1.3.0" - is-data-view "^1.0.1" - data-view-buffer@^1.0.2: version "1.0.2" - resolved "https://registry.yarnpkg.com/data-view-buffer/-/data-view-buffer-1.0.2.tgz#211a03ba95ecaf7798a8c7198d79536211f88570" + resolved "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz" integrity sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ== dependencies: call-bound "^1.0.3" es-errors "^1.3.0" is-data-view "^1.0.2" -data-view-byte-length@^1.0.1: - version "1.0.1" - resolved "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.1.tgz" - integrity sha512-4J7wRJD3ABAzr8wP+OcIcqq2dlUKp4DVflx++hs5h5ZKydWMI6/D/fAot+yh6g2tHh8fLFTvNOaVN357NvSrOQ== - dependencies: - call-bind "^1.0.7" - es-errors "^1.3.0" - is-data-view "^1.0.1" - data-view-byte-length@^1.0.2: version "1.0.2" - resolved "https://registry.yarnpkg.com/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz#9e80f7ca52453ce3e93d25a35318767ea7704735" + resolved "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz" integrity sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ== dependencies: call-bound "^1.0.3" es-errors "^1.3.0" is-data-view "^1.0.2" -data-view-byte-offset@^1.0.0: - version "1.0.0" - resolved "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.0.tgz" - integrity sha512-t/Ygsytq+R995EJ5PZlD4Cu56sWa8InXySaViRzw9apusqsOO2bQP+SbYzAhR0pFKoB+43lYy8rWban9JSuXnA== - dependencies: - call-bind "^1.0.6" - es-errors "^1.3.0" - is-data-view "^1.0.1" - data-view-byte-offset@^1.0.1: version "1.0.1" - resolved "https://registry.yarnpkg.com/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz#068307f9b71ab76dbbe10291389e020856606191" + resolved "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz" integrity sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ== dependencies: call-bound "^1.0.2" @@ -924,14 +822,6 @@ define-data-property@^1.0.1, define-data-property@^1.1.4: es-errors "^1.3.0" gopd "^1.0.1" -define-properties@^1.1.3, define-properties@^1.2.0: - version "1.2.0" - resolved "https://registry.npmjs.org/define-properties/-/define-properties-1.2.0.tgz" - integrity sha512-xvqAVKGfT1+UAvPwKTVw/njhdQ8ZhXK4lI0bCIuCMrp2up9nPnaDftrLtmpTazqd1o+UY4zgzU+avtMbDP+ldA== - dependencies: - has-property-descriptors "^1.0.0" - object-keys "^1.1.1" - define-properties@^1.2.1: version "1.2.1" resolved "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz" @@ -980,7 +870,7 @@ dom-serialize@^2.2.1: dunder-proto@^1.0.0, dunder-proto@^1.0.1: version "1.0.1" - resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a" + resolved "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz" integrity sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A== dependencies: call-bind-apply-helpers "^1.0.1" @@ -1022,17 +912,17 @@ engine.io-parser@~5.2.1: resolved "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz" integrity sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q== -engine.io@~6.6.0: - version "6.6.2" - resolved "https://registry.npmjs.org/engine.io/-/engine.io-6.6.2.tgz" - integrity sha512-gmNvsYi9C8iErnZdVcJnvCpSKbWTt1E8+JZo8b+daLninywUWi5NQ5STSHZ9rFjFO7imNcvb8Pc5pe/wMR5xEw== +engine.io@~6.5.2: + version "6.5.5" + resolved "https://registry.npmjs.org/engine.io/-/engine.io-6.5.5.tgz" + integrity sha512-C5Pn8Wk+1vKBoHghJODM63yk8MvrO9EWZUfkAt5HAqIgPE4/8FF0PEGHXtEd40l223+cE5ABWuPzm38PHFXfMA== dependencies: "@types/cookie" "^0.4.1" "@types/cors" "^2.8.12" "@types/node" ">=10.0.0" accepts "~1.3.4" base64id "2.0.0" - cookie "~0.7.2" + cookie "~0.4.1" cors "~2.8.5" debug "~4.3.1" engine.io-parser "~5.2.1" @@ -1043,61 +933,9 @@ ent@~2.2.0: resolved "https://registry.npmjs.org/ent/-/ent-2.2.0.tgz" integrity sha512-GHrMyVZQWvTIdDtpiEXdHZnFQKzeO09apj8Cbl4pKWy4i0Oprcq17usfDt5aO63swf0JOeMWjWQE/LzgSRuWpA== -es-abstract@^1.22.1, es-abstract@^1.22.3, es-abstract@^1.23.0: - version "1.23.3" - resolved "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.3.tgz" - integrity sha512-e+HfNH61Bj1X9/jLc5v1owaLYuHdeHHSQlkhCBiTK8rBvKaULl/beGMxwrMXjpYrv4pz22BlY570vVePA2ho4A== - dependencies: - array-buffer-byte-length "^1.0.1" - arraybuffer.prototype.slice "^1.0.3" - available-typed-arrays "^1.0.7" - call-bind "^1.0.7" - data-view-buffer "^1.0.1" - data-view-byte-length "^1.0.1" - data-view-byte-offset "^1.0.0" - es-define-property "^1.0.0" - es-errors "^1.3.0" - es-object-atoms "^1.0.0" - es-set-tostringtag "^2.0.3" - es-to-primitive "^1.2.1" - function.prototype.name "^1.1.6" - get-intrinsic "^1.2.4" - get-symbol-description "^1.0.2" - globalthis "^1.0.3" - gopd "^1.0.1" - has-property-descriptors "^1.0.2" - has-proto "^1.0.3" - has-symbols "^1.0.3" - hasown "^2.0.2" - internal-slot "^1.0.7" - is-array-buffer "^3.0.4" - is-callable "^1.2.7" - is-data-view "^1.0.1" - is-negative-zero "^2.0.3" - is-regex "^1.1.4" - is-shared-array-buffer "^1.0.3" - is-string "^1.0.7" - is-typed-array "^1.1.13" - is-weakref "^1.0.2" - object-inspect "^1.13.1" - object-keys "^1.1.1" - object.assign "^4.1.5" - regexp.prototype.flags "^1.5.2" - safe-array-concat "^1.1.2" - safe-regex-test "^1.0.3" - string.prototype.trim "^1.2.9" - string.prototype.trimend "^1.0.8" - string.prototype.trimstart "^1.0.8" - typed-array-buffer "^1.0.2" - typed-array-byte-length "^1.0.1" - typed-array-byte-offset "^1.0.2" - typed-array-length "^1.0.6" - unbox-primitive "^1.0.2" - which-typed-array "^1.1.15" - es-abstract@^1.23.2, es-abstract@^1.23.5, es-abstract@^1.23.9, es-abstract@^1.24.0: version "1.24.0" - resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.24.0.tgz#c44732d2beb0acc1ed60df840869e3106e7af328" + resolved "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz" integrity sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg== dependencies: array-buffer-byte-length "^1.0.2" @@ -1155,49 +993,26 @@ es-abstract@^1.23.2, es-abstract@^1.23.5, es-abstract@^1.23.9, es-abstract@^1.24 unbox-primitive "^1.1.0" which-typed-array "^1.1.19" -es-define-property@^1.0.0: - version "1.0.0" - resolved "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz" - integrity sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ== - dependencies: - get-intrinsic "^1.2.4" - -es-define-property@^1.0.1: +es-define-property@^1.0.0, es-define-property@^1.0.1: version "1.0.1" - resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.1.tgz#983eb2f9a6724e9303f61addf011c72e09e0b0fa" + resolved "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz" integrity sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g== -es-errors@^1.2.1, es-errors@^1.3.0: +es-errors@^1.3.0: version "1.3.0" resolved "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz" integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== -es-object-atoms@^1.0.0: - version "1.0.0" - resolved "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.0.tgz" - integrity sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw== - dependencies: - es-errors "^1.3.0" - -es-object-atoms@^1.1.1: +es-object-atoms@^1.0.0, es-object-atoms@^1.1.1: version "1.1.1" - resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz#1c4f2c4837327597ce69d2ca190a7fdd172338c1" + resolved "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz" integrity sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA== dependencies: es-errors "^1.3.0" -es-set-tostringtag@^2.0.3: - version "2.0.3" - resolved "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.3.tgz" - integrity sha512-3T8uNMC3OQTHkFUsFq8r/BwAXLHvU/9O9mE0fBc/MY5iq/8H7ncvO947LmYA6ldWw9Uh8Yhf25zu6n7nML5QWQ== - dependencies: - get-intrinsic "^1.2.4" - has-tostringtag "^1.0.2" - hasown "^2.0.1" - es-set-tostringtag@^2.1.0: version "2.1.0" - resolved "https://registry.yarnpkg.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz#f31dbbe0c183b00a6d26eb6325c810c0fd18bd4d" + resolved "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz" integrity sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA== dependencies: es-errors "^1.3.0" @@ -1205,32 +1020,16 @@ es-set-tostringtag@^2.1.0: has-tostringtag "^1.0.2" hasown "^2.0.2" -es-shim-unscopables@^1.0.0: - version "1.0.0" - resolved "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.0.0.tgz" - integrity sha512-Jm6GPcCdC30eMLbZ2x8z2WuRwAws3zTBBKuusffYVUrNj/GVSUAZ+xKMaUpfNDR5IbyNA5LJbaecoUVbmUcB1w== - dependencies: - has "^1.0.3" - -es-shim-unscopables@^1.1.0: +es-shim-unscopables@^1.0.2, es-shim-unscopables@^1.1.0: version "1.1.0" - resolved "https://registry.yarnpkg.com/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz#438df35520dac5d105f3943d927549ea3b00f4b5" + resolved "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz" integrity sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw== dependencies: hasown "^2.0.2" -es-to-primitive@^1.2.1: - version "1.2.1" - resolved "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz" - integrity sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA== - dependencies: - is-callable "^1.1.4" - is-date-object "^1.0.1" - is-symbol "^1.0.2" - es-to-primitive@^1.3.0: version "1.3.0" - resolved "https://registry.yarnpkg.com/es-to-primitive/-/es-to-primitive-1.3.0.tgz#96c89c82cc49fd8794a24835ba3e1ff87f214e18" + resolved "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz" integrity sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g== dependencies: is-callable "^1.2.7" @@ -1278,69 +1077,69 @@ eslint-import-resolver-node@^0.3.9: is-core-module "^2.13.0" resolve "^1.22.4" -eslint-module-utils@^2.12.0: +eslint-module-utils@^2.12.1: version "2.12.1" - resolved "https://registry.yarnpkg.com/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz#f76d3220bfb83c057651359295ab5854eaad75ff" + resolved "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz" integrity sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw== dependencies: debug "^3.2.7" eslint-plugin-import@^2.31.0: - version "2.31.0" - resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.31.0.tgz#310ce7e720ca1d9c0bb3f69adfd1c6bdd7d9e0e7" - integrity sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A== + version "2.32.0" + resolved "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz" + integrity sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA== dependencies: "@rtsao/scc" "^1.1.0" - array-includes "^3.1.8" - array.prototype.findlastindex "^1.2.5" - array.prototype.flat "^1.3.2" - array.prototype.flatmap "^1.3.2" + array-includes "^3.1.9" + array.prototype.findlastindex "^1.2.6" + array.prototype.flat "^1.3.3" + array.prototype.flatmap "^1.3.3" debug "^3.2.7" doctrine "^2.1.0" eslint-import-resolver-node "^0.3.9" - eslint-module-utils "^2.12.0" + eslint-module-utils "^2.12.1" hasown "^2.0.2" - is-core-module "^2.15.1" + is-core-module "^2.16.1" is-glob "^4.0.3" minimatch "^3.1.2" object.fromentries "^2.0.8" object.groupby "^1.0.3" - object.values "^1.2.0" + object.values "^1.2.1" semver "^6.3.1" - string.prototype.trimend "^1.0.8" + string.prototype.trimend "^1.0.9" tsconfig-paths "^3.15.0" -eslint-scope@^8.3.0: +eslint-scope@^8.4.0: version "8.4.0" - resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-8.4.0.tgz#88e646a207fad61436ffa39eb505147200655c82" + resolved "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz" integrity sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg== dependencies: esrecurse "^4.3.0" estraverse "^5.2.0" -eslint-visitor-keys@^3.3.0: +eslint-visitor-keys@^3.4.3: version "3.4.3" resolved "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz" integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag== -eslint-visitor-keys@^4.2.0, eslint-visitor-keys@^4.2.1: +eslint-visitor-keys@^4.2.1: version "4.2.1" - resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz#4cfea60fe7dd0ad8e816e1ed026c1d5251b512c1" + resolved "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz" integrity sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ== eslint@^9.24.0: - version "9.24.0" - resolved "https://registry.yarnpkg.com/eslint/-/eslint-9.24.0.tgz#9a7f2e6cb2de81c405ab244b02f4584c79dc6bee" - integrity sha512-eh/jxIEJyZrvbWRe4XuVclLPDYSYYYgLy5zXGGxD6j8zjSAxFEzI2fL/8xNq6O2yKqVt+eF2YhV+hxjV6UKXwQ== + version "9.37.0" + resolved "https://registry.npmjs.org/eslint/-/eslint-9.37.0.tgz" + integrity sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig== dependencies: - "@eslint-community/eslint-utils" "^4.2.0" + "@eslint-community/eslint-utils" "^4.8.0" "@eslint-community/regexpp" "^4.12.1" - "@eslint/config-array" "^0.20.0" - "@eslint/config-helpers" "^0.2.0" - "@eslint/core" "^0.12.0" + "@eslint/config-array" "^0.21.0" + "@eslint/config-helpers" "^0.4.0" + "@eslint/core" "^0.16.0" "@eslint/eslintrc" "^3.3.1" - "@eslint/js" "9.24.0" - "@eslint/plugin-kit" "^0.2.7" + "@eslint/js" "9.37.0" + "@eslint/plugin-kit" "^0.4.0" "@humanfs/node" "^0.16.6" "@humanwhocodes/module-importer" "^1.0.1" "@humanwhocodes/retry" "^0.4.2" @@ -1351,9 +1150,9 @@ eslint@^9.24.0: cross-spawn "^7.0.6" debug "^4.3.2" escape-string-regexp "^4.0.0" - eslint-scope "^8.3.0" - eslint-visitor-keys "^4.2.0" - espree "^10.3.0" + eslint-scope "^8.4.0" + eslint-visitor-keys "^4.2.1" + espree "^10.4.0" esquery "^1.5.0" esutils "^2.0.2" fast-deep-equal "^3.1.3" @@ -1369,9 +1168,9 @@ eslint@^9.24.0: natural-compare "^1.4.0" optionator "^0.9.3" -espree@^10.0.1, espree@^10.3.0: +espree@^10.0.1, espree@^10.4.0: version "10.4.0" - resolved "https://registry.yarnpkg.com/espree/-/espree-10.4.0.tgz#d54f4949d4629005a1fa168d937c3ff1f7e2a837" + resolved "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz" integrity sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ== dependencies: acorn "^8.15.0" @@ -1379,9 +1178,9 @@ espree@^10.0.1, espree@^10.3.0: eslint-visitor-keys "^4.2.1" esquery@^1.5.0: - version "1.6.0" - resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.6.0.tgz#91419234f804d852a82dceec3e16cdc22cf9dae7" - integrity sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg== + version "1.5.0" + resolved "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz" + integrity sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg== dependencies: estraverse "^5.1.0" @@ -1444,7 +1243,7 @@ fast-levenshtein@^2.0.6: file-entry-cache@^8.0.0: version "8.0.0" - resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz#7787bddcf1131bffb92636c69457bbc0edd6d81f" + resolved "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz" integrity sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ== dependencies: flat-cache "^4.0.0" @@ -1479,37 +1278,25 @@ find-up@^5.0.0: flat-cache@^4.0.0: version "4.0.1" - resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-4.0.1.tgz#0ece39fcb14ee012f4b0410bd33dd9c1f011127c" + resolved "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz" integrity sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw== dependencies: flatted "^3.2.9" keyv "^4.5.4" -flatted@^3.2.7: +flatted@^3.2.7, flatted@^3.2.9: version "3.3.1" resolved "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz" integrity sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw== -flatted@^3.2.9: - version "3.3.3" - resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.3.3.tgz#67c8fad95454a7c7abebf74bb78ee74a44023358" - integrity sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg== - follow-redirects@^1.0.0: version "1.15.2" resolved "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz" integrity sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA== -for-each@^0.3.3: - version "0.3.3" - resolved "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz" - integrity sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw== - dependencies: - is-callable "^1.1.3" - -for-each@^0.3.5: +for-each@^0.3.3, for-each@^0.3.5: version "0.3.5" - resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.5.tgz#d650688027826920feeb0af747ee7b9421a41d47" + resolved "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz" integrity sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg== dependencies: is-callable "^1.2.7" @@ -1559,24 +1346,14 @@ fsevents@~2.3.2: resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== -function-bind@^1.1.1, function-bind@^1.1.2: +function-bind@^1.1.2: version "1.1.2" resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz" integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== -function.prototype.name@^1.1.6: - version "1.1.6" - resolved "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.6.tgz" - integrity sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg== - dependencies: - call-bind "^1.0.2" - define-properties "^1.2.0" - es-abstract "^1.22.1" - functions-have-names "^1.2.3" - -function.prototype.name@^1.1.8: +function.prototype.name@^1.1.6, function.prototype.name@^1.1.8: version "1.1.8" - resolved "https://registry.yarnpkg.com/function.prototype.name/-/function.prototype.name-1.1.8.tgz#e68e1df7b259a5c949eeef95cdbde53edffabb78" + resolved "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz" integrity sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q== dependencies: call-bind "^1.0.8" @@ -1591,25 +1368,19 @@ functions-have-names@^1.2.3: resolved "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz" integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ== +generator-function@^2.0.0: + version "2.0.1" + resolved "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz" + integrity sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g== + get-caller-file@^2.0.5: version "2.0.5" resolved "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz" integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== -get-intrinsic@^1.1.3, get-intrinsic@^1.2.1, get-intrinsic@^1.2.3, get-intrinsic@^1.2.4: - version "1.2.4" - resolved "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz" - integrity sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ== - dependencies: - es-errors "^1.3.0" - function-bind "^1.1.2" - has-proto "^1.0.1" - has-symbols "^1.0.3" - hasown "^2.0.0" - -get-intrinsic@^1.2.5, get-intrinsic@^1.2.6, get-intrinsic@^1.2.7, get-intrinsic@^1.3.0: +get-intrinsic@^1.2.4, get-intrinsic@^1.2.5, get-intrinsic@^1.2.6, get-intrinsic@^1.2.7, get-intrinsic@^1.3.0: version "1.3.0" - resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01" + resolved "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz" integrity sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ== dependencies: call-bind-apply-helpers "^1.0.2" @@ -1623,26 +1394,17 @@ get-intrinsic@^1.2.5, get-intrinsic@^1.2.6, get-intrinsic@^1.2.7, get-intrinsic@ hasown "^2.0.2" math-intrinsics "^1.1.0" -get-proto@^1.0.0, get-proto@^1.0.1: +get-proto@^1.0.1: version "1.0.1" - resolved "https://registry.yarnpkg.com/get-proto/-/get-proto-1.0.1.tgz#150b3f2743869ef3e851ec0c49d15b1d14d00ee1" + resolved "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz" integrity sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g== dependencies: dunder-proto "^1.0.1" es-object-atoms "^1.0.0" -get-symbol-description@^1.0.2: - version "1.0.2" - resolved "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.2.tgz" - integrity sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg== - dependencies: - call-bind "^1.0.5" - es-errors "^1.3.0" - get-intrinsic "^1.2.4" - get-symbol-description@^1.1.0: version "1.1.0" - resolved "https://registry.yarnpkg.com/get-symbol-description/-/get-symbol-description-1.1.0.tgz#7bdd54e0befe8ffc9f3b4e203220d9f1e881b6ee" + resolved "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz" integrity sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg== dependencies: call-bound "^1.0.3" @@ -1684,19 +1446,12 @@ glob@^7.1.3, glob@^7.1.4, glob@^7.1.6, glob@^7.1.7: globals@^14.0.0: version "14.0.0" - resolved "https://registry.yarnpkg.com/globals/-/globals-14.0.0.tgz#898d7413c29babcf6bafe56fcadded858ada724e" + resolved "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz" integrity sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ== -globalthis@^1.0.3: - version "1.0.3" - resolved "https://registry.npmjs.org/globalthis/-/globalthis-1.0.3.tgz" - integrity sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA== - dependencies: - define-properties "^1.1.3" - globalthis@^1.0.4: version "1.0.4" - resolved "https://registry.yarnpkg.com/globalthis/-/globalthis-1.0.4.tgz#7430ed3a975d97bfb59bcce41f5cabbafa651236" + resolved "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz" integrity sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ== dependencies: define-properties "^1.2.1" @@ -1712,16 +1467,9 @@ globrex@^0.1.2: resolved "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz" integrity sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg== -gopd@^1.0.1: - version "1.0.1" - resolved "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz" - integrity sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA== - dependencies: - get-intrinsic "^1.1.3" - -gopd@^1.2.0: +gopd@^1.0.1, gopd@^1.2.0: version "1.2.0" - resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1" + resolved "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz" integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg== graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.6: @@ -1742,10 +1490,10 @@ har-validator@~5.1.0: ajv "^6.12.3" har-schema "^2.0.0" -has-bigints@^1.0.1, has-bigints@^1.0.2: - version "1.0.2" - resolved "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz" - integrity sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ== +has-bigints@^1.0.2: + version "1.1.0" + resolved "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz" + integrity sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg== has-flag@^3.0.0: version "3.0.0" @@ -1764,35 +1512,18 @@ has-property-descriptors@^1.0.0, has-property-descriptors@^1.0.2: dependencies: es-define-property "^1.0.0" -has-proto@^1.0.1, has-proto@^1.0.3: - version "1.0.3" - resolved "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz" - integrity sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q== - has-proto@^1.2.0: version "1.2.0" - resolved "https://registry.yarnpkg.com/has-proto/-/has-proto-1.2.0.tgz#5de5a6eabd95fdffd9818b43055e8065e39fe9d5" + resolved "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz" integrity sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ== dependencies: dunder-proto "^1.0.0" -has-symbols@^1.0.2, has-symbols@^1.0.3: - version "1.0.3" - resolved "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz" - integrity sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A== - -has-symbols@^1.1.0: +has-symbols@^1.0.3, has-symbols@^1.1.0: version "1.1.0" - resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.1.0.tgz#fc9c6a783a084951d0b971fe1018de813707a338" + resolved "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz" integrity sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ== -has-tostringtag@^1.0.0: - version "1.0.0" - resolved "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz" - integrity sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ== - dependencies: - has-symbols "^1.0.2" - has-tostringtag@^1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz" @@ -1800,14 +1531,7 @@ has-tostringtag@^1.0.2: dependencies: has-symbols "^1.0.3" -has@^1.0.3: - version "1.0.3" - resolved "https://registry.npmjs.org/has/-/has-1.0.3.tgz" - integrity sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw== - dependencies: - function-bind "^1.1.1" - -hasown@^2.0.0, hasown@^2.0.1, hasown@^2.0.2: +hasown@^2.0.2: version "2.0.2" resolved "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz" integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ== @@ -1902,35 +1626,18 @@ inherits@2, inherits@2.0.4, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.3: resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== -internal-slot@^1.0.7: - version "1.0.7" - resolved "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.7.tgz" - integrity sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g== - dependencies: - es-errors "^1.3.0" - hasown "^2.0.0" - side-channel "^1.0.4" - internal-slot@^1.1.0: version "1.1.0" - resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.1.0.tgz#1eac91762947d2f7056bc838d93e13b2e9604961" + resolved "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz" integrity sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw== dependencies: es-errors "^1.3.0" hasown "^2.0.2" side-channel "^1.1.0" -is-array-buffer@^3.0.4: - version "3.0.4" - resolved "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.4.tgz" - integrity sha512-wcjaerHw0ydZwfhiKbXJWLDY8A7yV7KhjQOpb83hGgGfId/aQa4TOvwyzn2PuswW2gPCYEL/nEAiSVpdOj1lXw== - dependencies: - call-bind "^1.0.2" - get-intrinsic "^1.2.1" - -is-array-buffer@^3.0.5: +is-array-buffer@^3.0.4, is-array-buffer@^3.0.5: version "3.0.5" - resolved "https://registry.yarnpkg.com/is-array-buffer/-/is-array-buffer-3.0.5.tgz#65742e1e687bd2cc666253068fd8707fe4d44280" + resolved "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz" integrity sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A== dependencies: call-bind "^1.0.8" @@ -1939,7 +1646,7 @@ is-array-buffer@^3.0.5: is-async-function@^2.0.0: version "2.1.1" - resolved "https://registry.yarnpkg.com/is-async-function/-/is-async-function-2.1.1.tgz#3e69018c8e04e73b738793d020bfe884b9fd3523" + resolved "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz" integrity sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ== dependencies: async-function "^1.0.0" @@ -1948,16 +1655,9 @@ is-async-function@^2.0.0: has-tostringtag "^1.0.2" safe-regex-test "^1.1.0" -is-bigint@^1.0.1: - version "1.0.4" - resolved "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz" - integrity sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg== - dependencies: - has-bigints "^1.0.1" - is-bigint@^1.1.0: version "1.1.0" - resolved "https://registry.yarnpkg.com/is-bigint/-/is-bigint-1.1.0.tgz#dda7a3445df57a42583db4228682eba7c4170672" + resolved "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz" integrity sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ== dependencies: has-bigints "^1.0.2" @@ -1969,67 +1669,38 @@ is-binary-path@~2.1.0: dependencies: binary-extensions "^2.0.0" -is-boolean-object@^1.1.0: - version "1.1.2" - resolved "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz" - integrity sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA== - dependencies: - call-bind "^1.0.2" - has-tostringtag "^1.0.0" - is-boolean-object@^1.2.1: version "1.2.2" - resolved "https://registry.yarnpkg.com/is-boolean-object/-/is-boolean-object-1.2.2.tgz#7067f47709809a393c71ff5bb3e135d8a9215d9e" + resolved "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz" integrity sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A== dependencies: call-bound "^1.0.3" has-tostringtag "^1.0.2" -is-callable@^1.1.3, is-callable@^1.1.4, is-callable@^1.2.7: +is-callable@^1.2.7: version "1.2.7" resolved "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz" integrity sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA== -is-core-module@^2.11.0, is-core-module@^2.13.0: - version "2.13.1" - resolved "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz" - integrity sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw== - dependencies: - hasown "^2.0.0" - -is-core-module@^2.15.1: +is-core-module@^2.11.0, is-core-module@^2.13.0, is-core-module@^2.16.1: version "2.16.1" - resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.16.1.tgz#2a98801a849f43e2add644fbb6bc6229b19a4ef4" + resolved "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz" integrity sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w== dependencies: hasown "^2.0.2" -is-data-view@^1.0.1: - version "1.0.1" - resolved "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.1.tgz" - integrity sha512-AHkaJrsUVW6wq6JS8y3JnM/GJF/9cf+k20+iDzlSaJrinEo5+7vRiteOSwBhHRiAyQATN1AmY4hwzxJKPmYf+w== - dependencies: - is-typed-array "^1.1.13" - -is-data-view@^1.0.2: +is-data-view@^1.0.1, is-data-view@^1.0.2: version "1.0.2" - resolved "https://registry.yarnpkg.com/is-data-view/-/is-data-view-1.0.2.tgz#bae0a41b9688986c2188dda6657e56b8f9e63b8e" + resolved "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz" integrity sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw== dependencies: call-bound "^1.0.2" get-intrinsic "^1.2.6" is-typed-array "^1.1.13" -is-date-object@^1.0.1: - version "1.0.5" - resolved "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz" - integrity sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ== - dependencies: - has-tostringtag "^1.0.0" - is-date-object@^1.0.5, is-date-object@^1.1.0: version "1.1.0" - resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.1.0.tgz#ad85541996fc7aa8b2729701d27b7319f95d82f7" + resolved "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz" integrity sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg== dependencies: call-bound "^1.0.2" @@ -2042,7 +1713,7 @@ is-extglob@^2.1.1: is-finalizationregistry@^1.1.0: version "1.1.1" - resolved "https://registry.yarnpkg.com/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz#eefdcdc6c94ddd0674d9c85887bf93f944a97c90" + resolved "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz" integrity sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg== dependencies: call-bound "^1.0.3" @@ -2053,12 +1724,13 @@ is-fullwidth-code-point@^3.0.0: integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== is-generator-function@^1.0.10: - version "1.1.0" - resolved "https://registry.yarnpkg.com/is-generator-function/-/is-generator-function-1.1.0.tgz#bf3eeda931201394f57b5dba2800f91a238309ca" - integrity sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ== + version "1.1.2" + resolved "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz" + integrity sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA== dependencies: - call-bound "^1.0.3" - get-proto "^1.0.0" + call-bound "^1.0.4" + generator-function "^2.0.0" + get-proto "^1.0.1" has-tostringtag "^1.0.2" safe-regex-test "^1.1.0" @@ -2071,7 +1743,7 @@ is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1: is-map@^2.0.3: version "2.0.3" - resolved "https://registry.yarnpkg.com/is-map/-/is-map-2.0.3.tgz#ede96b7fe1e270b3c4465e3a465658764926d62e" + resolved "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz" integrity sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw== is-module@^1.0.0: @@ -2084,16 +1756,9 @@ is-negative-zero@^2.0.3: resolved "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz" integrity sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw== -is-number-object@^1.0.4: - version "1.0.7" - resolved "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.7.tgz" - integrity sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ== - dependencies: - has-tostringtag "^1.0.0" - is-number-object@^1.1.1: version "1.1.1" - resolved "https://registry.yarnpkg.com/is-number-object/-/is-number-object-1.1.1.tgz#144b21e95a1bc148205dcc2814a9134ec41b2541" + resolved "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz" integrity sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw== dependencies: call-bound "^1.0.3" @@ -2111,17 +1776,9 @@ is-reference@^1.2.1: dependencies: "@types/estree" "*" -is-regex@^1.1.4: - version "1.1.4" - resolved "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz" - integrity sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg== - dependencies: - call-bind "^1.0.2" - has-tostringtag "^1.0.0" - is-regex@^1.2.1: version "1.2.1" - resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.2.1.tgz#76d70a3ed10ef9be48eb577887d74205bf0cad22" + resolved "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz" integrity sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g== dependencies: call-bound "^1.0.2" @@ -2131,64 +1788,36 @@ is-regex@^1.2.1: is-set@^2.0.3: version "2.0.3" - resolved "https://registry.yarnpkg.com/is-set/-/is-set-2.0.3.tgz#8ab209ea424608141372ded6e0cb200ef1d9d01d" + resolved "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz" integrity sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg== -is-shared-array-buffer@^1.0.2, is-shared-array-buffer@^1.0.3: - version "1.0.3" - resolved "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.3.tgz" - integrity sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg== - dependencies: - call-bind "^1.0.7" - is-shared-array-buffer@^1.0.4: version "1.0.4" - resolved "https://registry.yarnpkg.com/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz#9b67844bd9b7f246ba0708c3a93e34269c774f6f" + resolved "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz" integrity sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A== dependencies: call-bound "^1.0.3" -is-string@^1.0.5, is-string@^1.0.7: - version "1.0.7" - resolved "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz" - integrity sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg== - dependencies: - has-tostringtag "^1.0.0" - is-string@^1.1.1: version "1.1.1" - resolved "https://registry.yarnpkg.com/is-string/-/is-string-1.1.1.tgz#92ea3f3d5c5b6e039ca8677e5ac8d07ea773cbb9" + resolved "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz" integrity sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA== dependencies: call-bound "^1.0.3" has-tostringtag "^1.0.2" -is-symbol@^1.0.2, is-symbol@^1.0.3: - version "1.0.4" - resolved "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz" - integrity sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg== - dependencies: - has-symbols "^1.0.2" - is-symbol@^1.0.4, is-symbol@^1.1.1: version "1.1.1" - resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.1.1.tgz#f47761279f532e2b05a7024a7506dbbedacd0634" + resolved "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz" integrity sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w== dependencies: call-bound "^1.0.2" has-symbols "^1.1.0" safe-regex-test "^1.1.0" -is-typed-array@^1.1.13: - version "1.1.13" - resolved "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.13.tgz" - integrity sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw== - dependencies: - which-typed-array "^1.1.14" - -is-typed-array@^1.1.14, is-typed-array@^1.1.15: +is-typed-array@^1.1.13, is-typed-array@^1.1.14, is-typed-array@^1.1.15: version "1.1.15" - resolved "https://registry.yarnpkg.com/is-typed-array/-/is-typed-array-1.1.15.tgz#4bfb4a45b61cee83a5a46fba778e4e8d59c0ce0b" + resolved "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz" integrity sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ== dependencies: which-typed-array "^1.1.16" @@ -2200,26 +1829,19 @@ is-typedarray@~1.0.0: is-weakmap@^2.0.2: version "2.0.2" - resolved "https://registry.yarnpkg.com/is-weakmap/-/is-weakmap-2.0.2.tgz#bf72615d649dfe5f699079c54b83e47d1ae19cfd" + resolved "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz" integrity sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w== -is-weakref@^1.0.2: - version "1.0.2" - resolved "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz" - integrity sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ== - dependencies: - call-bind "^1.0.2" - -is-weakref@^1.1.1: +is-weakref@^1.0.2, is-weakref@^1.1.1: version "1.1.1" - resolved "https://registry.yarnpkg.com/is-weakref/-/is-weakref-1.1.1.tgz#eea430182be8d64174bd96bffbc46f21bf3f9293" + resolved "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz" integrity sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew== dependencies: call-bound "^1.0.3" is-weakset@^2.0.3: version "2.0.4" - resolved "https://registry.yarnpkg.com/is-weakset/-/is-weakset-2.0.4.tgz#c9f5deb0bc1906c6d6f1027f284ddf459249daca" + resolved "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz" integrity sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ== dependencies: call-bound "^1.0.3" @@ -2278,7 +1900,7 @@ jsbn@~0.1.0: json-buffer@3.0.1: version "3.0.1" - resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.1.tgz#9338802a30d3b6605fbe0613e094008ca8c05a13" + resolved "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz" integrity sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ== json-schema-traverse@^0.4.1: @@ -2380,7 +2002,7 @@ karma@^6.4.2: keyv@^4.5.4: version "4.5.4" - resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93" + resolved "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz" integrity sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw== dependencies: json-buffer "3.0.1" @@ -2462,7 +2084,7 @@ magic-string@^0.25.7: math-intrinsics@^1.1.0: version "1.1.0" - resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9" + resolved "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz" integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g== media-typer@0.3.0: @@ -2523,16 +2145,11 @@ ms@2.0.0: resolved "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz" integrity sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A== -ms@2.1.2: +ms@2.1.2, ms@^2.1.1: version "2.1.2" resolved "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== -ms@^2.1.1: - version "2.1.3" - resolved "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz" - integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== - natural-compare@^1.4.0: version "1.4.0" resolved "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz" @@ -2568,14 +2185,9 @@ object-assign@^4: resolved "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz" integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== -object-inspect@^1.13.1: - version "1.13.1" - resolved "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz" - integrity sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ== - object-inspect@^1.13.3, object-inspect@^1.13.4: version "1.13.4" - resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.4.tgz#8375265e21bc20d0fa582c22e1b13485d6e00213" + resolved "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz" integrity sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew== object-keys@^1.1.1: @@ -2583,19 +2195,9 @@ object-keys@^1.1.1: resolved "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz" integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA== -object.assign@^4.1.5: - version "4.1.5" - resolved "https://registry.npmjs.org/object.assign/-/object.assign-4.1.5.tgz" - integrity sha512-byy+U7gp+FVwmyzKPYhW2h5l3crpmGsxl7X2s8y43IgxvG4g3QZ6CffDtsNQy1WsmZpQbO+ybo0AlW7TY6DcBQ== - dependencies: - call-bind "^1.0.5" - define-properties "^1.2.1" - has-symbols "^1.0.3" - object-keys "^1.1.1" - object.assign@^4.1.7: version "4.1.7" - resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.7.tgz#8c14ca1a424c6a561b0bb2a22f66f5049a945d3d" + resolved "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz" integrity sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw== dependencies: call-bind "^1.0.8" @@ -2607,7 +2209,7 @@ object.assign@^4.1.7: object.fromentries@^2.0.8: version "2.0.8" - resolved "https://registry.yarnpkg.com/object.fromentries/-/object.fromentries-2.0.8.tgz#f7195d8a9b97bd95cbc1999ea939ecd1a2b00c65" + resolved "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz" integrity sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ== dependencies: call-bind "^1.0.7" @@ -2617,16 +2219,16 @@ object.fromentries@^2.0.8: object.groupby@^1.0.3: version "1.0.3" - resolved "https://registry.yarnpkg.com/object.groupby/-/object.groupby-1.0.3.tgz#9b125c36238129f6f7b61954a1e7176148d5002e" + resolved "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz" integrity sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ== dependencies: call-bind "^1.0.7" define-properties "^1.2.1" es-abstract "^1.23.2" -object.values@^1.2.0: +object.values@^1.2.1: version "1.2.1" - resolved "https://registry.yarnpkg.com/object.values/-/object.values-1.2.1.tgz#deed520a50809ff7f75a7cfd4bc64c7a038c6216" + resolved "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz" integrity sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA== dependencies: call-bind "^1.0.8" @@ -2669,7 +2271,7 @@ optionator@^0.9.3: own-keys@^1.0.1: version "1.0.1" - resolved "https://registry.yarnpkg.com/own-keys/-/own-keys-1.0.1.tgz#e4006910a2bf913585289676eebd6f390cf51358" + resolved "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz" integrity sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg== dependencies: get-intrinsic "^1.2.6" @@ -2727,20 +2329,15 @@ performance-now@^2.1.0: resolved "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz" integrity sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow== -picocolors@^1.0.0: - version "1.1.1" - resolved "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz" - integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== - picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.2: version "2.3.1" resolved "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz" integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== possible-typed-array-names@^1.0.0: - version "1.0.0" - resolved "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz" - integrity sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q== + version "1.1.0" + resolved "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz" + integrity sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg== prelude-ls@^1.2.1: version "1.2.1" @@ -2851,7 +2448,7 @@ readdirp@~3.6.0: reflect.getprototypeof@^1.0.6, reflect.getprototypeof@^1.0.9: version "1.0.10" - resolved "https://registry.yarnpkg.com/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz#c629219e78a3316d8b604c765ef68996964e7bf9" + resolved "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz" integrity sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw== dependencies: call-bind "^1.0.8" @@ -2863,19 +2460,9 @@ reflect.getprototypeof@^1.0.6, reflect.getprototypeof@^1.0.9: get-proto "^1.0.1" which-builtin-type "^1.2.1" -regexp.prototype.flags@^1.5.2: - version "1.5.2" - resolved "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.2.tgz" - integrity sha512-NcDiDkTLuPR+++OCKB0nWafEmhg/Da8aUPLPMQbK+bxKKCm1/S5he+AqYa4PlMCVBalb4/yxIRub6qkEx5yJbw== - dependencies: - call-bind "^1.0.6" - define-properties "^1.2.1" - es-errors "^1.3.0" - set-function-name "^2.0.1" - regexp.prototype.flags@^1.5.4: version "1.5.4" - resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz#1ad6c62d44a259007e55b3970e00f746efbcaa19" + resolved "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz" integrity sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA== dependencies: call-bind "^1.0.8" @@ -2945,9 +2532,9 @@ resolve@^1.22.4: supports-preserve-symlinks-flag "^1.0.0" rfdc@^1.3.0: - version "1.4.1" - resolved "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz" - integrity sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA== + version "1.3.1" + resolved "https://registry.npmjs.org/rfdc/-/rfdc-1.3.1.tgz" + integrity sha512-r5a3l5HzYlIC68TpmYKlxWjmOP6wiPJ1vWv2HeLhNsRZMrCkxeqxiHlQ21oXmQ4F3SiryXBHhAD7JZqvOJjFmg== rimraf@^2.5.4: version "2.7.1" @@ -2980,19 +2567,9 @@ rollup@^2.35.1: optionalDependencies: fsevents "~2.3.2" -safe-array-concat@^1.1.2: - version "1.1.2" - resolved "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.2.tgz" - integrity sha512-vj6RsCsWBCf19jIeHEfkRMw8DPiBb+DMXklQ/1SGDHOMlHdPUkZXFQ2YdplS23zESTijAcurb1aSgJA3AgMu1Q== - dependencies: - call-bind "^1.0.7" - get-intrinsic "^1.2.4" - has-symbols "^1.0.3" - isarray "^2.0.5" - safe-array-concat@^1.1.3: version "1.1.3" - resolved "https://registry.yarnpkg.com/safe-array-concat/-/safe-array-concat-1.1.3.tgz#c9e54ec4f603b0bbb8e7e5007a5ee7aecd1538c3" + resolved "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz" integrity sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q== dependencies: call-bind "^1.0.8" @@ -3013,24 +2590,15 @@ safe-buffer@~5.1.0, safe-buffer@~5.1.1: safe-push-apply@^1.0.0: version "1.0.0" - resolved "https://registry.yarnpkg.com/safe-push-apply/-/safe-push-apply-1.0.0.tgz#01850e981c1602d398c85081f360e4e6d03d27f5" + resolved "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz" integrity sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA== dependencies: es-errors "^1.3.0" isarray "^2.0.5" -safe-regex-test@^1.0.3: - version "1.0.3" - resolved "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.3.tgz" - integrity sha512-CdASjNJPvRa7roO6Ra/gLYBTzYzzPyyBXxIMdGW3USQLyjWEls2RgW5UBTXaQVp+OrpeCK3bLem8smtmheoRuw== - dependencies: - call-bind "^1.0.6" - es-errors "^1.3.0" - is-regex "^1.1.4" - safe-regex-test@^1.1.0: version "1.1.0" - resolved "https://registry.yarnpkg.com/safe-regex-test/-/safe-regex-test-1.1.0.tgz#7f87dfb67a3150782eaaf18583ff5d1711ac10c1" + resolved "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz" integrity sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw== dependencies: call-bound "^1.0.2" @@ -3072,7 +2640,7 @@ serialize-javascript@^4.0.0: dependencies: randombytes "^2.1.0" -set-function-length@^1.2.1, set-function-length@^1.2.2: +set-function-length@^1.2.2: version "1.2.2" resolved "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz" integrity sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg== @@ -3084,7 +2652,7 @@ set-function-length@^1.2.1, set-function-length@^1.2.2: gopd "^1.0.1" has-property-descriptors "^1.0.2" -set-function-name@^2.0.1, set-function-name@^2.0.2: +set-function-name@^2.0.2: version "2.0.2" resolved "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz" integrity sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ== @@ -3096,7 +2664,7 @@ set-function-name@^2.0.1, set-function-name@^2.0.2: set-proto@^1.0.0: version "1.0.0" - resolved "https://registry.yarnpkg.com/set-proto/-/set-proto-1.0.0.tgz#0760dbcff30b2d7e801fd6e19983e56da337565e" + resolved "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz" integrity sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw== dependencies: dunder-proto "^1.0.1" @@ -3122,7 +2690,7 @@ shebang-regex@^3.0.0: side-channel-list@^1.0.0: version "1.0.0" - resolved "https://registry.yarnpkg.com/side-channel-list/-/side-channel-list-1.0.0.tgz#10cb5984263115d3b7a0e336591e290a830af8ad" + resolved "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz" integrity sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA== dependencies: es-errors "^1.3.0" @@ -3130,7 +2698,7 @@ side-channel-list@^1.0.0: side-channel-map@^1.0.1: version "1.0.1" - resolved "https://registry.yarnpkg.com/side-channel-map/-/side-channel-map-1.0.1.tgz#d6bb6b37902c6fef5174e5f533fab4c732a26f42" + resolved "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz" integrity sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA== dependencies: call-bound "^1.0.2" @@ -3140,7 +2708,7 @@ side-channel-map@^1.0.1: side-channel-weakmap@^1.0.2: version "1.0.2" - resolved "https://registry.yarnpkg.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz#11dda19d5368e40ce9ec2bdc1fb0ecbc0790ecea" + resolved "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz" integrity sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A== dependencies: call-bound "^1.0.2" @@ -3149,19 +2717,9 @@ side-channel-weakmap@^1.0.2: object-inspect "^1.13.3" side-channel-map "^1.0.1" -side-channel@^1.0.4: - version "1.0.6" - resolved "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz" - integrity sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA== - dependencies: - call-bind "^1.0.7" - es-errors "^1.3.0" - get-intrinsic "^1.2.4" - object-inspect "^1.13.1" - -side-channel@^1.1.0: +side-channel@^1.0.4, side-channel@^1.1.0: version "1.1.0" - resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.1.0.tgz#c3fcff9c4da932784873335ec9765fa94ff66bc9" + resolved "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz" integrity sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw== dependencies: es-errors "^1.3.0" @@ -3187,15 +2745,15 @@ socket.io-parser@~4.2.4: debug "~4.3.1" socket.io@^4.7.2: - version "4.8.0" - resolved "https://registry.npmjs.org/socket.io/-/socket.io-4.8.0.tgz" - integrity sha512-8U6BEgGjQOfGz3HHTYaC/L1GaxDCJ/KM0XTkJly0EhZ5U/du9uNEZy4ZgYzEzIqlx2CMm25CrCqr1ck899eLNA== + version "4.7.5" + resolved "https://registry.npmjs.org/socket.io/-/socket.io-4.7.5.tgz" + integrity sha512-DmeAkF6cwM9jSfmp6Dr/5/mfMwb5Z5qRrSXLpo3Fq5SqyU8CMF15jIN4ZhfSwu35ksM1qmHZDQ/DK5XTccSTvA== dependencies: accepts "~1.3.4" base64id "~2.0.0" cors "~2.8.5" debug "~4.3.2" - engine.io "~6.6.0" + engine.io "~6.5.2" socket.io-adapter "~2.5.2" socket.io-parser "~4.2.4" @@ -3249,7 +2807,7 @@ statuses@~1.5.0: stop-iteration-iterator@^1.1.0: version "1.1.0" - resolved "https://registry.yarnpkg.com/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz#f481ff70a548f6124d0312c3aa14cbfa7aa542ad" + resolved "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz" integrity sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ== dependencies: es-errors "^1.3.0" @@ -3275,7 +2833,7 @@ string-width@^4.1.0, string-width@^4.2.0: string.prototype.trim@^1.2.10: version "1.2.10" - resolved "https://registry.yarnpkg.com/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz#40b2dd5ee94c959b4dcfb1d65ce72e90da480c81" + resolved "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz" integrity sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA== dependencies: call-bind "^1.0.8" @@ -3286,28 +2844,9 @@ string.prototype.trim@^1.2.10: es-object-atoms "^1.0.0" has-property-descriptors "^1.0.2" -string.prototype.trim@^1.2.9: - version "1.2.9" - resolved "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.9.tgz" - integrity sha512-klHuCNxiMZ8MlsOihJhJEBJAiMVqU3Z2nEXWfWnIqjN0gEFS9J9+IxKozWWtQGcgoa1WUZzLjKPTr4ZHNFTFxw== - dependencies: - call-bind "^1.0.7" - define-properties "^1.2.1" - es-abstract "^1.23.0" - es-object-atoms "^1.0.0" - -string.prototype.trimend@^1.0.8: - version "1.0.8" - resolved "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.8.tgz" - integrity sha512-p73uL5VCHCO2BZZ6krwwQE3kCzM7NKmis8S//xEC6fQonchbum4eP6kR4DLEjQFO3Wnj3Fuo8NM0kOSjVdHjZQ== - dependencies: - call-bind "^1.0.7" - define-properties "^1.2.1" - es-object-atoms "^1.0.0" - string.prototype.trimend@^1.0.9: version "1.0.9" - resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz#62e2731272cd285041b36596054e9f66569b6942" + resolved "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz" integrity sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ== dependencies: call-bind "^1.0.8" @@ -3386,9 +2925,9 @@ tar-stream@^2.1.0: readable-stream "^3.1.1" terser@^5.0.0: - version "5.36.0" - resolved "https://registry.npmjs.org/terser/-/terser-5.36.0.tgz" - integrity sha512-IYV9eNMuFAV4THUspIRXkLakHnV6XO7FEdtKjf/mDyrnqUg9LnlOn6/RwRvM9SZjR4GUq8Nk8zj67FzVARr74w== + version "5.18.2" + resolved "https://registry.npmjs.org/terser/-/terser-5.18.2.tgz" + integrity sha512-Ah19JS86ypbJzTzvUCX7KOsEIhDaRONungA4aYBjEP3JZRf4ocuDzTg4QWZnPn9DEMiMYGJPiSOy7aykoCc70w== dependencies: "@jridgewell/source-map" "^0.3.3" acorn "^8.8.2" @@ -3429,13 +2968,13 @@ tough-cookie@~2.4.3: punycode "^1.4.1" trix@^2.0.0: - version "2.1.7" - resolved "https://registry.npmjs.org/trix/-/trix-2.1.7.tgz" - integrity sha512-RyFmjLJfxP2nuAKqgVqJ40wk4qoYfDQtyi71q6ozkP+X4EOILe+j5ll5g/suvTyMx7BacGszNWzjnx9Vbj17sw== + version "2.0.5" + resolved "https://registry.npmjs.org/trix/-/trix-2.0.5.tgz" + integrity sha512-OiCbDf17F7JahEwhyL1MvK9DxAAT1vkaW5sn+zpwfemZAcc4RfQB4ku18/1mKP58LRwBhjcy+6TBho7ciXz52Q== tsconfig-paths@^3.15.0: version "3.15.0" - resolved "https://registry.yarnpkg.com/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz#5299ec605e55b1abb23ec939ef15edaf483070d4" + resolved "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz" integrity sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg== dependencies: "@types/json5" "^0.0.29" @@ -3470,38 +3009,18 @@ type-is@~1.6.18: media-typer "0.3.0" mime-types "~2.1.24" -typed-array-buffer@^1.0.2: - version "1.0.2" - resolved "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.2.tgz" - integrity sha512-gEymJYKZtKXzzBzM4jqa9w6Q1Jjm7x2d+sh19AdsD4wqnMPDYyvwpsIc2Q/835kHuo3BEQ7CjelGhfTsoBb2MQ== - dependencies: - call-bind "^1.0.7" - es-errors "^1.3.0" - is-typed-array "^1.1.13" - typed-array-buffer@^1.0.3: version "1.0.3" - resolved "https://registry.yarnpkg.com/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz#a72395450a4869ec033fd549371b47af3a2ee536" + resolved "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz" integrity sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw== dependencies: call-bound "^1.0.3" es-errors "^1.3.0" is-typed-array "^1.1.14" -typed-array-byte-length@^1.0.1: - version "1.0.1" - resolved "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.1.tgz" - integrity sha512-3iMJ9q0ao7WE9tWcaYKIptkNBuOIcZCCT0d4MRvuuH88fEoEH62IuQe0OtraD3ebQEoTRk8XCBoknUNc1Y67pw== - dependencies: - call-bind "^1.0.7" - for-each "^0.3.3" - gopd "^1.0.1" - has-proto "^1.0.3" - is-typed-array "^1.1.13" - typed-array-byte-length@^1.0.3: version "1.0.3" - resolved "https://registry.yarnpkg.com/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz#8407a04f7d78684f3d252aa1a143d2b77b4160ce" + resolved "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz" integrity sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg== dependencies: call-bind "^1.0.8" @@ -3510,21 +3029,9 @@ typed-array-byte-length@^1.0.3: has-proto "^1.2.0" is-typed-array "^1.1.14" -typed-array-byte-offset@^1.0.2: - version "1.0.2" - resolved "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.2.tgz" - integrity sha512-Ous0vodHa56FviZucS2E63zkgtgrACj7omjwd/8lTEMEPFFyjfixMZ1ZXenpgCFBBt4EC1J2XsyVS2gkG0eTFA== - dependencies: - available-typed-arrays "^1.0.7" - call-bind "^1.0.7" - for-each "^0.3.3" - gopd "^1.0.1" - has-proto "^1.0.3" - is-typed-array "^1.1.13" - typed-array-byte-offset@^1.0.4: version "1.0.4" - resolved "https://registry.yarnpkg.com/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz#ae3698b8ec91a8ab945016108aef00d5bff12355" + resolved "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz" integrity sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ== dependencies: available-typed-arrays "^1.0.7" @@ -3535,21 +3042,9 @@ typed-array-byte-offset@^1.0.4: is-typed-array "^1.1.15" reflect.getprototypeof "^1.0.9" -typed-array-length@^1.0.6: - version "1.0.6" - resolved "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.6.tgz" - integrity sha512-/OxDN6OtAk5KBpGb28T+HZc2M+ADtvRxXrKKbUwtsLgdoxgX13hyy7ek6bFRl5+aBs2yZzB0c4CnQfAtVypW/g== - dependencies: - call-bind "^1.0.7" - for-each "^0.3.3" - gopd "^1.0.1" - has-proto "^1.0.3" - is-typed-array "^1.1.13" - possible-typed-array-names "^1.0.0" - typed-array-length@^1.0.7: version "1.0.7" - resolved "https://registry.yarnpkg.com/typed-array-length/-/typed-array-length-1.0.7.tgz#ee4deff984b64be1e118b0de8c9c877d5ce73d3d" + resolved "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz" integrity sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg== dependencies: call-bind "^1.0.7" @@ -3564,19 +3059,9 @@ ua-parser-js@^0.7.30: resolved "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.39.tgz" integrity sha512-IZ6acm6RhQHNibSt7+c09hhvsKy9WUr4DVbeq9U8o71qxyYtJpQeDxQnMrVqnIFMLcQjHO0I9wgfO2vIahht4w== -unbox-primitive@^1.0.2: - version "1.0.2" - resolved "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz" - integrity sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw== - dependencies: - call-bind "^1.0.2" - has-bigints "^1.0.2" - has-symbols "^1.0.3" - which-boxed-primitive "^1.0.2" - unbox-primitive@^1.1.0: version "1.1.0" - resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.1.0.tgz#8d9d2c9edeea8460c7f35033a88867944934d1e2" + resolved "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz" integrity sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw== dependencies: call-bound "^1.0.3" @@ -3584,11 +3069,6 @@ unbox-primitive@^1.1.0: has-symbols "^1.1.0" which-boxed-primitive "^1.1.1" -undici-types@~6.19.2: - version "6.19.8" - resolved "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz" - integrity sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw== - universalify@^0.1.0: version "0.1.2" resolved "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz" @@ -3663,20 +3143,9 @@ wd@^1.4.0: request "2.88.0" vargs "^0.1.0" -which-boxed-primitive@^1.0.2: - version "1.0.2" - resolved "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz" - integrity sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg== - dependencies: - is-bigint "^1.0.1" - is-boolean-object "^1.1.0" - is-number-object "^1.0.4" - is-string "^1.0.5" - is-symbol "^1.0.3" - which-boxed-primitive@^1.1.0, which-boxed-primitive@^1.1.1: version "1.1.1" - resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz#d76ec27df7fa165f18d5808374a5fe23c29b176e" + resolved "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz" integrity sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA== dependencies: is-bigint "^1.1.0" @@ -3687,7 +3156,7 @@ which-boxed-primitive@^1.1.0, which-boxed-primitive@^1.1.1: which-builtin-type@^1.2.1: version "1.2.1" - resolved "https://registry.yarnpkg.com/which-builtin-type/-/which-builtin-type-1.2.1.tgz#89183da1b4907ab089a6b02029cc5d8d6574270e" + resolved "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz" integrity sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q== dependencies: call-bound "^1.0.2" @@ -3706,7 +3175,7 @@ which-builtin-type@^1.2.1: which-collection@^1.0.2: version "1.0.2" - resolved "https://registry.yarnpkg.com/which-collection/-/which-collection-1.0.2.tgz#627ef76243920a107e7ce8e96191debe4b16c2a0" + resolved "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz" integrity sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw== dependencies: is-map "^2.0.3" @@ -3714,20 +3183,9 @@ which-collection@^1.0.2: is-weakmap "^2.0.2" is-weakset "^2.0.3" -which-typed-array@^1.1.14, which-typed-array@^1.1.15: - version "1.1.15" - resolved "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.15.tgz" - integrity sha512-oV0jmFtUky6CXfkqehVvBP/LSWJ2sy4vWMioiENyJLePrBO/yKyV9OyJySfAKosh+RYkIl5zJCNZ8/4JncrpdA== - dependencies: - available-typed-arrays "^1.0.7" - call-bind "^1.0.7" - for-each "^0.3.3" - gopd "^1.0.1" - has-tostringtag "^1.0.2" - which-typed-array@^1.1.16, which-typed-array@^1.1.19: version "1.1.19" - resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.19.tgz#df03842e870b6b88e117524a4b364b6fc689f956" + resolved "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz" integrity sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw== dependencies: available-typed-arrays "^1.0.7" From f1f60dc12779f4237a5cbb4ba28428952813ba86 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Mon, 13 Oct 2025 08:25:51 -0400 Subject: [PATCH 0777/1075] Introduce SQLite3Adapter.resolve_path A SQLite database may be configured with a relative path, an absolute path, or a nonstandard `file:` URIs (possibly with params). When it is a URI, it's non-trivial to determine the filesystem path to the database. This commit introduces a new singleton method on the connection adapter to reliably resolve a filesystem path from the database configuration. It uses this method in the connection adapter's initializer to ensure the database file's directory exists. Previously the adapter omitted this check for database URIs. It also introduces usage of this method in the `create` and `drop` database tasks which also previously did not fully support database URIs. --- activerecord/CHANGELOG.md | 6 ++ .../connection_adapters/sqlite3_adapter.rb | 34 ++++++++- .../tasks/sqlite_database_tasks.rb | 6 +- .../adapters/sqlite3/sqlite3_adapter_test.rb | 75 +++++++++++++++++++ .../adapters/sqlite3/sqlite_rake_test.rb | 36 +++------ 5 files changed, 124 insertions(+), 33 deletions(-) diff --git a/activerecord/CHANGELOG.md b/activerecord/CHANGELOG.md index 217c0b0118811..9d24a07dfb65a 100644 --- a/activerecord/CHANGELOG.md +++ b/activerecord/CHANGELOG.md @@ -1,2 +1,8 @@ +* Improve support for SQLite database URIs. + + The `db:create` and `db:drop` tasks now correctly handle SQLite database URIs, and the + SQLite3Adapter will create the parent directory if it does not exist. + + *Mike Dalessio* Please check [8-1-stable](https://github.com/rails/rails/blob/8-1-stable/activerecord/CHANGELOG.md) for previous changes. diff --git a/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb b/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb index 5bd517c0b4eb1..7f8ffcd0bcd15 100644 --- a/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb @@ -70,6 +70,33 @@ def dbconsole(config, options = {}) def native_database_types # :nodoc: NATIVE_DATABASE_TYPES end + + # Returns a filesystem path to the database. + # + # The configuration's :database value may be a (slightly nonstandard) SQLite URI, so this + # method will resolve those URIs to a string path. + # + # If Rails.root is available, this is guaranteed to be an absolute path. + # + # See https://www.sqlite.org/uri.html + def resolve_path(database, root: nil) + database = database.to_s + root ||= defined?(Rails.root) ? Rails.root : nil + + path = if database.start_with?("file:/") + URI.parse(database).path + elsif database.start_with?("file:") + URI.parse(database.split("?").first).opaque + else + database + end + + if root.present? + File.expand_path(path, root) + else + path + end + end end include SQLite3::Quoting @@ -135,11 +162,10 @@ def initialize(...) raise ArgumentError, "No database file specified. Missing argument: database" when ":memory:" @memory_database = true - when /\Afile:/ else - # Otherwise we have a path relative to Rails.root - @config[:database] = File.expand_path(@config[:database], Rails.root) if defined?(Rails.root) - dirname = File.dirname(@config[:database]) + database_path = SQLite3Adapter.resolve_path(@config[:database]) + @config[:database] = database_path unless @config[:database].to_s.start_with?("file:") + dirname = File.dirname(database_path) unless File.directory?(dirname) begin FileUtils.mkdir_p(dirname) diff --git a/activerecord/lib/active_record/tasks/sqlite_database_tasks.rb b/activerecord/lib/active_record/tasks/sqlite_database_tasks.rb index d5b4dfc0d9843..510c9096a0f30 100644 --- a/activerecord/lib/active_record/tasks/sqlite_database_tasks.rb +++ b/activerecord/lib/active_record/tasks/sqlite_database_tasks.rb @@ -9,15 +9,15 @@ def initialize(db_config, root = ActiveRecord::Tasks::DatabaseTasks.root) end def create - raise DatabaseAlreadyExists if File.exist?(db_config.database) + file = ConnectionAdapters::SQLite3Adapter.resolve_path(db_config.database) + raise DatabaseAlreadyExists if File.exist?(file) establish_connection connection end def drop - db_path = db_config.database - file = File.absolute_path?(db_path) ? db_path : File.join(root, db_path) + file = ConnectionAdapters::SQLite3Adapter.resolve_path(db_config.database, root: root) FileUtils.rm(file) FileUtils.rm_f(["#{file}-shm", "#{file}-wal"]) rescue Errno::ENOENT => error diff --git a/activerecord/test/cases/adapters/sqlite3/sqlite3_adapter_test.rb b/activerecord/test/cases/adapters/sqlite3/sqlite3_adapter_test.rb index 6c6297c802a02..8e6e5468b45b4 100644 --- a/activerecord/test/cases/adapters/sqlite3/sqlite3_adapter_test.rb +++ b/activerecord/test/cases/adapters/sqlite3/sqlite3_adapter_test.rb @@ -1119,7 +1119,82 @@ def new_client(config) assert_equal(["/string/literal/path", SQLiteExtensionSpec], conn.class.new_client_arg[:extensions]) end + test "path resolution of a relative file path" do + database = "storage/production/main.sqlite3" + assert_equal("storage/production/main.sqlite3", SQLite3Adapter.resolve_path(database)) + assert_equal("/foo/storage/production/main.sqlite3", SQLite3Adapter.resolve_path(database, root: "/foo")) + + with_rails_root do + assert_equal("/app/storage/production/main.sqlite3", SQLite3Adapter.resolve_path(database)) + assert_equal("/foo/storage/production/main.sqlite3", SQLite3Adapter.resolve_path(database, root: "/foo")) + end + end + + test "path resolution of an absolute file path" do + database = "/var/storage/production/main.sqlite3" + assert_equal("/var/storage/production/main.sqlite3", SQLite3Adapter.resolve_path(database)) + assert_equal("/var/storage/production/main.sqlite3", SQLite3Adapter.resolve_path(database, root: "/foo")) + + with_rails_root do + assert_equal("/var/storage/production/main.sqlite3", SQLite3Adapter.resolve_path(database)) + assert_equal("/var/storage/production/main.sqlite3", SQLite3Adapter.resolve_path(database, root: "/foo")) + end + end + + test "path resolution of an absolute URI" do + database = "file:/var/storage/production/main.sqlite3" + assert_equal("/var/storage/production/main.sqlite3", SQLite3Adapter.resolve_path(database)) + assert_equal("/var/storage/production/main.sqlite3", SQLite3Adapter.resolve_path(database, root: "/foo")) + + with_rails_root do + assert_equal("/var/storage/production/main.sqlite3", SQLite3Adapter.resolve_path(database)) + assert_equal("/var/storage/production/main.sqlite3", SQLite3Adapter.resolve_path(database, root: "/foo")) + end + end + + test "path resolution of an absolute URI with query params" do + database = "file:/var/storage/production/main.sqlite3?vfs=unix-dotfile" + assert_equal("/var/storage/production/main.sqlite3", SQLite3Adapter.resolve_path(database)) + assert_equal("/var/storage/production/main.sqlite3", SQLite3Adapter.resolve_path(database, root: "/foo")) + + with_rails_root do + assert_equal("/var/storage/production/main.sqlite3", SQLite3Adapter.resolve_path(database)) + assert_equal("/var/storage/production/main.sqlite3", SQLite3Adapter.resolve_path(database, root: "/foo")) + end + end + + test "path resolution of a relative URI" do + database = "file:storage/production/main.sqlite3" + assert_equal("storage/production/main.sqlite3", SQLite3Adapter.resolve_path(database)) + assert_equal("/foo/storage/production/main.sqlite3", SQLite3Adapter.resolve_path(database, root: "/foo")) + + with_rails_root do + assert_equal("/app/storage/production/main.sqlite3", SQLite3Adapter.resolve_path(database)) + assert_equal("/foo/storage/production/main.sqlite3", SQLite3Adapter.resolve_path(database, root: "/foo")) + end + end + + test "path resolution of a relative URI with query params" do + database = "file:storage/production/main.sqlite3?vfs=unix-dotfile" + assert_equal("storage/production/main.sqlite3", SQLite3Adapter.resolve_path(database)) + assert_equal("/foo/storage/production/main.sqlite3", SQLite3Adapter.resolve_path(database, root: "/foo")) + + with_rails_root do + assert_equal("/app/storage/production/main.sqlite3", SQLite3Adapter.resolve_path(database)) + assert_equal("/foo/storage/production/main.sqlite3", SQLite3Adapter.resolve_path(database, root: "/foo")) + end + end + private + def with_rails_root(&block) + mod = Module.new do + def self.root + Pathname.new("/app") + end + end + stub_const(Object, :Rails, mod, &block) + end + def assert_logged(logs) subscriber = SQLSubscriber.new subscription = ActiveSupport::Notifications.subscribe("sql.active_record", subscriber) diff --git a/activerecord/test/cases/adapters/sqlite3/sqlite_rake_test.rb b/activerecord/test/cases/adapters/sqlite3/sqlite_rake_test.rb index 469a9a05e4b9c..5d8128a2a1e79 100644 --- a/activerecord/test/cases/adapters/sqlite3/sqlite_rake_test.rb +++ b/activerecord/test/cases/adapters/sqlite3/sqlite_rake_test.rb @@ -22,8 +22,10 @@ def teardown def test_db_checks_database_exists ActiveRecord::Base.stub(:establish_connection, nil) do - assert_called_with(File, :exist?, [@database], returns: false) do - ActiveRecord::Tasks::DatabaseTasks.create @configuration, "/rails/root" + assert_called_with(ConnectionAdapters::SQLite3Adapter, :resolve_path, [@database], returns: @database_root) do + assert_called_with(File, :exist?, [@database_root], returns: false) do + ActiveRecord::Tasks::DatabaseTasks.create @configuration, "/rails/root" + end end end end @@ -91,30 +93,12 @@ def teardown $stdout, $stderr = @original_stdout, @original_stderr end - def test_checks_db_dir_is_absolute - assert_called_with(File, :absolute_path?, [@database], returns: false) do - ActiveRecord::Tasks::DatabaseTasks.drop @configuration, @root - end - end - - def test_removes_file_with_absolute_path - assert_called_with(FileUtils, :rm, [@database_root]) do - assert_called_with(FileUtils, :rm_f, [["#{@database_root}-shm", "#{@database_root}-wal"]]) do - ActiveRecord::Tasks::DatabaseTasks.drop @configuration_root, @root - end - end - end - - def test_generates_absolute_path_with_given_root - assert_called_with(File, :join, [@root, @database], returns: "#{@root}/#{@database}") do - ActiveRecord::Tasks::DatabaseTasks.drop @configuration, @root - end - end - - def test_removes_file_with_relative_path - assert_called_with(FileUtils, :rm, [@database_root]) do - assert_called_with(FileUtils, :rm_f, [["#{@database_root}-shm", "#{@database_root}-wal"]]) do - ActiveRecord::Tasks::DatabaseTasks.drop @configuration, @root + def test_removes_fully_resolved_db_path + assert_called_with(ConnectionAdapters::SQLite3Adapter, :resolve_path, [@database], root: "/rails/root", returns: @database_root) do + assert_called_with(FileUtils, :rm, [@database_root]) do + assert_called_with(FileUtils, :rm_f, [["#{@database_root}-shm", "#{@database_root}-wal"]]) do + ActiveRecord::Tasks::DatabaseTasks.drop @configuration, @root + end end end end From a7d6dc30b69a0144acb2eef8e9cd5dae56d8a628 Mon Sep 17 00:00:00 2001 From: Julien ANNE Date: Wed, 15 Oct 2025 10:04:21 +0200 Subject: [PATCH 0778/1075] Add advisory DB update by default in bin/bundler-audit Ensure the advisory DB for CVEs is up to date to avoid false positives. --- railties/CHANGELOG.md | 4 ++++ .../rails/generators/rails/app/templates/bin/bundler-audit.tt | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/railties/CHANGELOG.md b/railties/CHANGELOG.md index c3b66e7697626..c11dcea759043 100644 --- a/railties/CHANGELOG.md +++ b/railties/CHANGELOG.md @@ -2,4 +2,8 @@ *Ryan Kulp* +* Add `--update` option to the `bin/bundler-audit` script. + + *Julien ANNE* + Please check [8-1-stable](https://github.com/rails/rails/blob/8-1-stable/railties/CHANGELOG.md) for previous changes. diff --git a/railties/lib/rails/generators/rails/app/templates/bin/bundler-audit.tt b/railties/lib/rails/generators/rails/app/templates/bin/bundler-audit.tt index 03f9f430aa946..154f659713b18 100644 --- a/railties/lib/rails/generators/rails/app/templates/bin/bundler-audit.tt +++ b/railties/lib/rails/generators/rails/app/templates/bin/bundler-audit.tt @@ -1,5 +1,5 @@ require_relative "../config/boot" require "bundler/audit/cli" -ARGV.concat %w[ --config config/bundler-audit.yml ] if ARGV.empty? || ARGV.include?("check") +ARGV.concat %w[ --update --config config/bundler-audit.yml ] if ARGV.empty? || ARGV.include?("check") Bundler::Audit::CLI.start From e0ae228272f36cedca8ecff18f6cf6d0e58e5691 Mon Sep 17 00:00:00 2001 From: Petrik Date: Wed, 15 Oct 2025 14:08:14 +0200 Subject: [PATCH 0779/1075] Show help hint when starting `bin/rails console` The help command in the Rails console shows which Rails specific commands are available and what they do. Similar to `bin/rails dbconsole` (where database clients show a help hint), show a hint on how to use help when opening `bin/rails console`. ``` $ bin/rails console Loading development environment (Rails 8.1.0.beta1) Type 'help' for help. example(dev):001> ``` --- railties/CHANGELOG.md | 4 ++++ railties/lib/rails/commands/console/console_command.rb | 1 + railties/test/commands/console_test.rb | 2 ++ 3 files changed, 7 insertions(+) diff --git a/railties/CHANGELOG.md b/railties/CHANGELOG.md index c3b66e7697626..46e99e387c453 100644 --- a/railties/CHANGELOG.md +++ b/railties/CHANGELOG.md @@ -1,3 +1,7 @@ +* Show help hint when starting `bin/rails console` + + *Petrik de Heus* + * Persist `/rails/info/routes` search query and results between page reloads. *Ryan Kulp* diff --git a/railties/lib/rails/commands/console/console_command.rb b/railties/lib/rails/commands/console/console_command.rb index e8d48d6d287af..6e7bd86a15a89 100644 --- a/railties/lib/rails/commands/console/console_command.rb +++ b/railties/lib/rails/commands/console/console_command.rb @@ -55,6 +55,7 @@ def start else puts "Loading #{Rails.env} environment (Rails #{Rails.version})" end + puts "Type 'help' for help." console.start end diff --git a/railties/test/commands/console_test.rb b/railties/test/commands/console_test.rb index 7703da595b216..6a80d950decd5 100644 --- a/railties/test/commands/console_test.rb +++ b/railties/test/commands/console_test.rb @@ -46,6 +46,7 @@ def test_start assert_predicate app.console, :started? assert_match(/Loading \w+ environment \(Rails/, output) + assert_match(/Type 'help' for help/, output) end def test_start_with_sandbox @@ -54,6 +55,7 @@ def test_start_with_sandbox assert_predicate app.console, :started? assert app.sandbox assert_match(/Loading \w+ environment in sandbox \(Rails/, output) + assert_match(/Type 'help' for help/, output) end def test_console_with_environment From e60b82402439f4b0d8a9b9b0a7a0178239ba5de1 Mon Sep 17 00:00:00 2001 From: Jarrett Lusso Date: Tue, 16 Apr 2024 13:04:32 -0400 Subject: [PATCH 0780/1075] Add support for `expires_in:` when using `render` with `collection:` - Pass `expires_in:` to `write_multi` so the cache key is written with the expiration. - Added test. - Added documentation. --- actionview/CHANGELOG.md | 4 ++++ .../partial_renderer/collection_caching.rb | 15 ++++++++++++--- actionview/test/template/render_test.rb | 11 +++++++++++ guides/source/caching_with_rails.md | 8 ++++++++ 4 files changed, 35 insertions(+), 3 deletions(-) diff --git a/actionview/CHANGELOG.md b/actionview/CHANGELOG.md index ac7aaa27c78cf..ce15f3f53d5a9 100644 --- a/actionview/CHANGELOG.md +++ b/actionview/CHANGELOG.md @@ -1,2 +1,6 @@ Please check [8-1-stable](https://github.com/rails/rails/blob/8-1-stable/actionview/CHANGELOG.md) for previous changes. + +* Add `key:` and `expires_in:` options under `cached:` to `render` when used with `collection:` + + *Jarrett Lusso* diff --git a/actionview/lib/action_view/renderer/partial_renderer/collection_caching.rb b/actionview/lib/action_view/renderer/partial_renderer/collection_caching.rb index 6ee720aa4896b..94350f4f74dc1 100644 --- a/actionview/lib/action_view/renderer/partial_renderer/collection_caching.rb +++ b/actionview/lib/action_view/renderer/partial_renderer/collection_caching.rb @@ -51,12 +51,20 @@ def cache_collection_render(instrumentation_payload, view, template, collection) end end + def callable_cache_key + if @options[:cached].is_a?(Hash) && @options[:cached][:key].respond_to?(:call) + @options[:cached][:key] + elsif @options[:cached].respond_to?(:call) + @options[:cached] + end + end + def callable_cache_key? - @options[:cached].respond_to?(:call) + callable_cache_key.present? end def collection_by_cache_keys(view, template, collection) - seed = callable_cache_key? ? @options[:cached] : ->(i) { i } + seed = callable_cache_key? ? callable_cache_key : ->(i) { i } digest_path = view.digest_path_from_template(template) collection.preload! if callable_cache_key? @@ -111,7 +119,8 @@ def fetch_or_cache_partial(cached_partials, template, order_by:) end unless entries_to_write.empty? - collection_cache.write_multi(entries_to_write) + expires_in = @options[:cached][:expires_in] if @options[:cached].is_a?(Hash) + collection_cache.write_multi(entries_to_write, expires_in: expires_in) end keyed_partials diff --git a/actionview/test/template/render_test.rb b/actionview/test/template/render_test.rb index 9d1f197c67354..20cb0a00470a4 100644 --- a/actionview/test/template/render_test.rb +++ b/actionview/test/template/render_test.rb @@ -912,6 +912,17 @@ class CachedCustomer < Customer; end assert_equal "Hello: david", ActionView::PartialRenderer.collection_cache.read(key) end + test "template body written to cache with expiration when expires_in set" do + customer = Customer.new("jarrett", 2) + key = cache_key(customer, "test/_customer") + @view.render(partial: "test/customer", collection: [customer], cached: { expires_in: 1.hour }) + assert_equal "Hello: jarrett", ActionView::PartialRenderer.collection_cache.read(key) + + travel 2.hours + + assert_nil ActionView::PartialRenderer.collection_cache.read(key) + end + test "collection caching does not cache by default" do customer = Customer.new("david", 1) key = cache_key(customer, "test/_customer") diff --git a/guides/source/caching_with_rails.md b/guides/source/caching_with_rails.md index 92462ffe2feb3..1c329fd4d40be 100644 --- a/guides/source/caching_with_rails.md +++ b/guides/source/caching_with_rails.md @@ -123,6 +123,14 @@ do not overwrite each other: cached: ->(product) { [I18n.locale, product] } %> ``` +Additionally, you can configure `cached` with an options hash that takes `expires_in` and `key` so you can explicitly set the expiration. + +```html+erb +<%= render partial: 'products/product', + collection: @products, + cached: { expires_in: 1.hour, key: ->(product) { [I18n.locale, product] } } %> +``` + ### Russian Doll Caching You may want to nest cached fragments inside other cached fragments. This is From 26a5896c27ddc8ce7136d204648f3b5e49d4d0f4 Mon Sep 17 00:00:00 2001 From: Guillermo Iguaran Date: Wed, 15 Oct 2025 07:27:50 -0700 Subject: [PATCH 0781/1075] Fix CHANGELOG entry --- actionview/CHANGELOG.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/actionview/CHANGELOG.md b/actionview/CHANGELOG.md index ce15f3f53d5a9..125e9e657e080 100644 --- a/actionview/CHANGELOG.md +++ b/actionview/CHANGELOG.md @@ -1,6 +1,5 @@ - -Please check [8-1-stable](https://github.com/rails/rails/blob/8-1-stable/actionview/CHANGELOG.md) for previous changes. - * Add `key:` and `expires_in:` options under `cached:` to `render` when used with `collection:` *Jarrett Lusso* + +Please check [8-1-stable](https://github.com/rails/rails/blob/8-1-stable/actionview/CHANGELOG.md) for previous changes. From 06d3f59b36a9173ce725f8587d2948fcb200fec0 Mon Sep 17 00:00:00 2001 From: Guillermo Iguaran Date: Wed, 15 Oct 2025 08:29:14 -0700 Subject: [PATCH 0782/1075] Remove trailing whitespaces on the changelog --- actionview/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/actionview/CHANGELOG.md b/actionview/CHANGELOG.md index 125e9e657e080..6d1806339961f 100644 --- a/actionview/CHANGELOG.md +++ b/actionview/CHANGELOG.md @@ -1,5 +1,5 @@ * Add `key:` and `expires_in:` options under `cached:` to `render` when used with `collection:` *Jarrett Lusso* - + Please check [8-1-stable](https://github.com/rails/rails/blob/8-1-stable/actionview/CHANGELOG.md) for previous changes. From b379af17488a1d75d4218317c839e6b653ab3d05 Mon Sep 17 00:00:00 2001 From: kakudooo Date: Tue, 7 Oct 2025 23:46:03 +0900 Subject: [PATCH 0783/1075] Add error reporter to rails rake command Report unhandled exceptions to the error reporter raised from rake.top_level in Rails::Command::RakeCommand#perform. --- railties/CHANGELOG.md | 4 +++ .../lib/rails/commands/rake/rake_command.rb | 7 +++++- railties/test/commands/rake_test.rb | 25 +++++++++++++++++++ 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/railties/CHANGELOG.md b/railties/CHANGELOG.md index 46e99e387c453..c10880f57d78c 100644 --- a/railties/CHANGELOG.md +++ b/railties/CHANGELOG.md @@ -1,3 +1,7 @@ +* Report unhandled exceptions to the Error Reporter when running rake tasks via Rails command. + + *Akimichi Tanei* + * Show help hint when starting `bin/rails console` *Petrik de Heus* diff --git a/railties/lib/rails/commands/rake/rake_command.rb b/railties/lib/rails/commands/rake/rake_command.rb index 263fba3f5aa5a..24b56a3bfd2a1 100644 --- a/railties/lib/rails/commands/rake/rake_command.rb +++ b/railties/lib/rails/commands/rake/rake_command.rb @@ -24,7 +24,12 @@ def perform(task, args, config) end rake.options.suppress_backtrace_pattern = non_app_file_pattern - rake.standard_exception_handling { rake.top_level } + + rake.standard_exception_handling do + ActiveSupport.error_reporter.record(source: "rake_command.rails") do + rake.top_level + end + end end end diff --git a/railties/test/commands/rake_test.rb b/railties/test/commands/rake_test.rb index a95f67806febe..503807e4bb97b 100644 --- a/railties/test/commands/rake_test.rb +++ b/railties/test/commands/rake_test.rb @@ -1,9 +1,13 @@ # frozen_string_literal: true require "isolation/abstract_unit" +require "env_helpers" require "rails/command" +require "rails/commands/rake/rake_command" class Rails::Command::RakeTest < ActiveSupport::TestCase + include ActiveSupport::Testing::Isolation, EnvHelpers + setup :build_app teardown :teardown_app @@ -28,6 +32,27 @@ class Rails::Command::RakeTest < ActiveSupport::TestCase assert_match "Hello, World!", run_rake_command("greetings:hello[World]") end + test "error report and re-raises when task raises" do + app_file "lib/tasks/exception.rake", 'task(:exception) { raise StandardError, "rake error" }' + app_file "config/initializers/error_subscriber.rb", <<-RUBY + class ErrorSubscriber + def report(error, handled:, severity:, context:, source: nil) + Rails.logger.error(source) + end + end + + Rails.application.config.after_initialize do + Rails.error.subscribe(ErrorSubscriber.new) + end + RUBY + Rails.env = "test" + require "#{app_path}/config/environment" + assert_raises(StandardError) { run_rake_command("exception") } + + logs = File.read("#{app_path}/log/test.log") + assert_match("rake_command.rails\n", logs) + end + private def run_rake_command(*args, **options) rails args, **options From d81fbf33bc9c478fca57252255cbd0013fe137b6 Mon Sep 17 00:00:00 2001 From: fatkodima Date: Wed, 15 Oct 2025 19:08:28 +0300 Subject: [PATCH 0784/1075] Fix negative scopes for enums to include records with `nil` values --- activerecord/CHANGELOG.md | 4 ++++ activerecord/lib/active_record/enum.rb | 3 ++- activerecord/test/cases/enum_test.rb | 5 +++++ guides/source/upgrading_ruby_on_rails.md | 22 ++++++++++++++++++++++ 4 files changed, 33 insertions(+), 1 deletion(-) diff --git a/activerecord/CHANGELOG.md b/activerecord/CHANGELOG.md index 9d24a07dfb65a..fe1e499fc7c9f 100644 --- a/activerecord/CHANGELOG.md +++ b/activerecord/CHANGELOG.md @@ -1,3 +1,7 @@ +* Fix negative scopes for enums to include records with `nil` values. + + *fatkodima* + * Improve support for SQLite database URIs. The `db:create` and `db:drop` tasks now correctly handle SQLite database URIs, and the diff --git a/activerecord/lib/active_record/enum.rb b/activerecord/lib/active_record/enum.rb index 3bd4ecaaf2794..b515a22e904bb 100644 --- a/activerecord/lib/active_record/enum.rb +++ b/activerecord/lib/active_record/enum.rb @@ -318,7 +318,8 @@ def define_enum_methods(name, value_method_name, value, scopes, instance_methods # scope :not_active, -> { where.not(status: 0) } klass.send(:detect_enum_conflict!, name, "not_#{value_method_name}", true) - klass.scope "not_#{value_method_name}", -> { where.not(name => value) } + arel_column = klass.arel_table[name] + klass.scope "not_#{value_method_name}", -> { where(arel_column.not_eq(value).or(arel_column.eq(nil))) } end end end diff --git a/activerecord/test/cases/enum_test.rb b/activerecord/test/cases/enum_test.rb index 481db8bf86351..1c69a04dba6fe 100644 --- a/activerecord/test/cases/enum_test.rb +++ b/activerecord/test/cases/enum_test.rb @@ -87,6 +87,11 @@ class EnumTest < ActiveRecord::TestCase test "find via negative scope" do assert Book.not_published.exclude?(@book) assert Book.not_proposed.include?(@book) + + # Should include records with nils in the column. + rfr = books(:rfr) + rfr.update!(status: nil) + assert Book.not_published.include?(rfr) end test "find via where with values" do diff --git a/guides/source/upgrading_ruby_on_rails.md b/guides/source/upgrading_ruby_on_rails.md index fcba276bc6922..50cde0de621e6 100644 --- a/guides/source/upgrading_ruby_on_rails.md +++ b/guides/source/upgrading_ruby_on_rails.md @@ -82,6 +82,28 @@ Upgrading from Rails 8.1 to Rails 8.2 For more information on changes made to Rails 8.2 please see the [release notes](8_2_release_notes.html). +### The negative scopes for enums now include records with `nil` values. + +Active Record negative scopes for enums now include records with `nil` values. + +```ruby +class Book < ApplicationRecord + enum :status, [:proposed, :written, :published] +end + +book1 = Book.create!(status: :published) +book2 = Book.create!(status: :written) +book3 = Book.create!(status: nil) + +# Before + +Book.not_published # => [book2] + +# After + +Book.not_published # => [book2, book3] +``` + Upgrading from Rails 8.0 to Rails 8.1 ------------------------------------- From 8ee4ae23c9dbbe4f497b2d6332808194510b3a96 Mon Sep 17 00:00:00 2001 From: Sean Doyle Date: Wed, 10 Apr 2024 16:22:43 -0400 Subject: [PATCH 0785/1075] Extend `ActionMailer::TestCase` with multi-part assertions The Problem --- Prior to this change, testing the content of multi-part Action Mailer-generated `Mail` instances involved parsing content derived from each part into the appropriate type. For example, tests might read `mail.body.raw_source` from `text/plain` bodies into `String` instances and parse `text/html` bodies into HTML documents with Nokogiri. The Proposal --- This commit defines `assert_part` and `assert_no_part` as assertion methods on `ActionMailer::TestCase`. The `assert_part` method iterates over each available part, and asserts that the multi-part email includes a part that corresponds to the `content_type` argument. ```ruby test "assert against the HTML and text parts of the last delivered MyMailer.welcome mailer" do MyMailer.welcome("Hello, world").deliver_now assert_part :text do |text| assert_includes text, "Hello, world" end assert_part :html do |html| assert_dom html.root, "h1", "Hello, world" end assert_no_part :xml end test "assert against the HTML and text parts of a MyMailer.welcome mail instance" do mail = MyMailer.welcome("Hello, world") assert_part :text, mail do |text| assert_includes text, "Hello, world" end assert_part :html, mail do |html| assert_dom html.root, "h1", "Hello, world" end assert_no_part :xml, mail end ``` Additional details --- While HTML assertion support is already baked-into the gem's railtie, calls to `assert_part(:html)` make the fully-formed HTML document (a `Nokogiri::HTML4::Document` or `Nokogiri::HTML5::Document` instance) available to the block. The method also accepts a `Mail` instance directly. By comparison, the [assert_dom_email][] method (and its `assert_select_email` alias) provided by `Rails::Dom::Testing` yields an HTML fragment, and cannot assert against any `Mail` instance except for the last delivery. [Rails::Dom::Testing]: https://github.com/rails/rails-dom-testing [assert_dom_email]: https://github.com/rails/rails-dom-testing/blob/v2.3.0/lib/rails/dom/testing/assertions/selector_assertions.rb#L287-L317 --- actionmailer/CHANGELOG.md | 16 +++ actionmailer/lib/action_mailer/test_case.rb | 60 ++++++++++ actionmailer/test/assert_select_email_test.rb | 112 +++++++++++++++++- guides/source/testing.md | 30 +++++ 4 files changed, 215 insertions(+), 3 deletions(-) diff --git a/actionmailer/CHANGELOG.md b/actionmailer/CHANGELOG.md index f911e26cfe54d..9163901e39662 100644 --- a/actionmailer/CHANGELOG.md +++ b/actionmailer/CHANGELOG.md @@ -1,2 +1,18 @@ +* Add `assert_part` and `assert_no_part` to `ActionMailer::TestCase` + + ```ruby + test "assert MyMailer.welcome HTML and text parts" do + mail = MyMailer.welcome("Hello, world") + + assert_part :text, mail do |text| + assert_includes text, "Hello, world" + end + assert_part :html, mail do |html| + assert_dom html.root, "p", "Hello, world" + end + end + ``` + + *Sean Doyle* Please check [8-1-stable](https://github.com/rails/rails/blob/8-1-stable/actionmailer/CHANGELOG.md) for previous changes. diff --git a/actionmailer/lib/action_mailer/test_case.rb b/actionmailer/lib/action_mailer/test_case.rb index 1a07d8e79242e..8ec38edef00a0 100644 --- a/actionmailer/lib/action_mailer/test_case.rb +++ b/actionmailer/lib/action_mailer/test_case.rb @@ -38,6 +38,9 @@ module Behavior include Rails::Dom::Testing::Assertions::DomAssertions included do + class_attribute :_decoders, default: Hash.new(->(body) { body }).merge!( + Mime[:html] => ->(body) { Rails::Dom::Testing.html_document.parse(body) } + ).freeze # :nodoc: class_attribute :_mailer_class setup :initialize_test_deliveries setup :set_expected_mail @@ -83,6 +86,53 @@ def read_fixture(action) IO.readlines(File.join(Rails.root, "test", "fixtures", self.class.mailer_class.name.underscore, action)) end + # Assert that a Mail instance has a part matching the content type. + # If the Mail is multipart, extract and decode the appropriate part. Yield the decoded part to the block. + # + # By default, assert against the last delivered Mail. + # + # UsersMailer.create(user).deliver_now + # assert_part :text do |text| + # assert_includes text, "Welcome, #{user.email}" + # end + # assert_part :html do |html| + # assert_dom html.root, "h1", text: "Welcome, #{user.email}" + # end + # + # Assert against a Mail instance when provided + # + # mail = UsersMailer.create(user) + # assert_part :text, mail do |text| + # assert_includes text, "Welcome, #{user.email}" + # end + # assert_part :html, mail do |html| + # assert_dom html.root, "h1", text: "Welcome, #{user.email}" + # end + def assert_part(content_type, mail = last_delivered_mail!) + mime_type = Mime[content_type] + part = [*mail.parts, mail].find { |part| mime_type.match?(part.mime_type) } + decoder = _decoders[mime_type] + + assert_not_nil part, "expected part matching #{mime_type} in #{mail.inspect}" + + yield decoder.call(part.decoded) if block_given? + end + + # Assert that a Mail instance does not have a part with a matching MIME type + # + # By default, assert against the last delivered Mail. + # + # UsersMailer.create(user).deliver_now + # + # assert_no_part :html + # assert_no_part :text + def assert_no_part(content_type, mail = last_delivered_mail!) + mime_type = Mime[content_type] + part = [*mail.parts, mail].find { |part| mime_type.match?(part.mime_type) } + + assert_nil part, "expected no part matching #{mime_type} in #{mail.inspect}" + end + private def initialize_test_deliveries set_delivery_method :test @@ -119,6 +169,16 @@ def charset def encode(subject) Mail::Encodings.q_value_encode(subject, charset) end + + def last_delivered_mail + self.class.mailer_class.deliveries.last + end + + def last_delivered_mail! + last_delivered_mail.tap do |mail| + flunk "No e-mail in delivery list" if mail.nil? + end + end end include Behavior diff --git a/actionmailer/test/assert_select_email_test.rb b/actionmailer/test/assert_select_email_test.rb index 9699fe4000ded..5052f64d13016 100644 --- a/actionmailer/test/assert_select_email_test.rb +++ b/actionmailer/test/assert_select_email_test.rb @@ -10,15 +10,65 @@ def test(html) end end + tests AssertSelectMailer + + # + # Test assert_select_email + # + + def test_assert_select_email + assert_raise ActiveSupport::TestCase::Assertion do + assert_select_email { } + end + + AssertSelectMailer.test("

foo

bar

").deliver_now + assert_select_email do + assert_select "div:root" do + assert_select "p:first-child", "foo" + assert_select "p:last-child", "bar" + end + end + end + + def test_assert_part_last_mail_delivery + AssertSelectMailer.test("

foo

bar

").deliver_now + + assert_part :html do |html| + assert_kind_of Rails::Dom::Testing.html_document, html + + assert_dom html, "div" do + assert_dom "p:first-child", "foo" + assert_dom "p:last-child", "bar" + end + end + end + + def test_assert_part_with_mail_argument + mail = AssertSelectMailer.test("

foo

bar

") + + assert_part :html, mail do |html| + assert_kind_of Rails::Dom::Testing.html_document, html + + assert_dom html, "div" do + assert_dom "p:first-child", "foo" + assert_dom "p:last-child", "bar" + end + end + end +end + +class AssertMultipartSelectEmailTest < ActionMailer::TestCase class AssertMultipartSelectMailer < ActionMailer::Base def test(options) mail subject: "Test e-mail", from: "test@test.host", to: "test " do |format| - format.text { render plain: options[:text] } - format.html { render plain: options[:html] } + format.text { render plain: options[:text] } if options.key?(:text) + format.html { render plain: options[:html] } if options.key?(:html) end end end + tests AssertMultipartSelectMailer + # # Test assert_select_email # @@ -28,7 +78,7 @@ def test_assert_select_email assert_select_email { } end - AssertSelectMailer.test("

foo

bar

").deliver_now + AssertMultipartSelectMailer.test(html: "

foo

bar

", text: "foo bar").deliver_now assert_select_email do assert_select "div:root" do assert_select "p:first-child", "foo" @@ -46,4 +96,60 @@ def test_assert_select_email_multipart end end end + + def test_assert_part_last_mail_delivery + AssertMultipartSelectMailer.test(html: "

foo

bar

", text: "foo bar").deliver_now + + assert_part :text do |text| + assert_includes text, "foo bar" + end + assert_part :html do |html| + assert_kind_of Rails::Dom::Testing.html_document, html + + assert_dom html, "div" do + assert_dom "p:first-child", "foo" + assert_dom "p:last-child", "bar" + end + end + end + + def test_assert_part_with_mail_argument + mail = AssertMultipartSelectMailer.test(html: "

foo

bar

", text: "foo bar") + + assert_part :text, mail do |text| + assert_includes text, "foo bar" + end + assert_part :html, mail do |html| + assert_kind_of Rails::Dom::Testing.html_document, html + + assert_dom html, "div" do + assert_dom "p:first-child", "foo" + assert_dom "p:last-child", "bar" + end + end + end + + def test_assert_part_without_block + assert_part :html, AssertMultipartSelectMailer.test(html: "html") + assert_part :text, AssertMultipartSelectMailer.test(text: "text") + + assert_raises Minitest::Assertion, match: "expected part matching text/html" do + assert_part :html, AssertMultipartSelectMailer.test(text: "text") + end + assert_raises Minitest::Assertion, match: "expected part matching text/plain" do + assert_part :text, AssertMultipartSelectMailer.test(html: "html") + end + end + + def test_assert_no_part + assert_no_part :html, AssertMultipartSelectMailer.test(text: "text") + assert_no_part :text, AssertMultipartSelectMailer.test(html: "html") + + assert_raises Minitest::Assertion, match: "expected no part matching text/html" do + assert_no_part :html, AssertMultipartSelectMailer.test(html: "html") + end + assert_raises Minitest::Assertion, match: "expected no part matching text/plain" do + assert_no_part :text, AssertMultipartSelectMailer.test(text: "text") + end + end end diff --git a/guides/source/testing.md b/guides/source/testing.md index c74c3460c8f2a..df2f538bcdc80 100644 --- a/guides/source/testing.md +++ b/guides/source/testing.md @@ -2208,6 +2208,36 @@ You have been invited. Cheers! ``` +When testing multi-part emails with both HTML *and* text parts, use the +[`assert_part`](https://api.rubyonrails.org/classes/ActionMailer/TestCase/Behavior.html#method-i-assert_part) +assertion. When testing emails with HTML parts, use the assertions provided by [Rails::Dom::Testing](https://github.com/rails/rails-dom-testing). + +```ruby +require "test_helper" + +class UserMailerTest < ActionMailer::TestCase + test "invite" do + # Create the email and store it for further assertions + email = UserMailer.create_invite("me@example.com", + "friend@example.com", Time.now) + + # Test the body of the sent email's text part + assert_part :text, email do |text| + assert_includes text, "Hi friend@example.com" + assert_includes text, "You have been invited." + assert_includes text, "Cheers!" + end + + # Test the body of the sent email's HTML part + assert_part :html, email do |html| + assert_dom html, "h1", text: "Hi friend@example.com" + assert_dom html, "p", text: "You have been invited." + assert_dom html, "p", text: "Cheers!" + end + end +end +``` + #### Configuring the Delivery Method for Test The line `ActionMailer::Base.delivery_method = :test` in From 2645f07561d2c03e79efaa12f4588a2b921eac7d Mon Sep 17 00:00:00 2001 From: Chris Oliver Date: Wed, 15 Oct 2025 17:03:50 -0500 Subject: [PATCH 0786/1075] [RF-DOCS] Wishlists guide (#55428) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add wishlists guide * Update tutorials link * Update guides/source/wishlists.md * Apply suggestion from @rafaelfranca Co-authored-by: Rafael Mendonça França * Apply suggestion from @rafaelfranca Co-authored-by: Rafael Mendonça França * Apply suggestion from @rafaelfranca Co-authored-by: Rafael Mendonça França * Apply suggestion from @rafaelfranca Co-authored-by: Rafael Mendonça França * Apply suggestion from @rafaelfranca Co-authored-by: Rafael Mendonça França * Apply suggestion from @rafaelfranca Co-authored-by: Rafael Mendonça França * Apply suggestion from @MatheusRich Co-authored-by: Matheus Richard * Apply suggestion from @rafaelfranca Co-authored-by: Rafael Mendonça França * Apply suggestion from @rafaelfranca Co-authored-by: Rafael Mendonça França * Apply suggestion from @MatheusRich Co-authored-by: Matheus Richard * Apply suggestion from @MatheusRich Co-authored-by: Matheus Richard * Apply suggestion from @MatheusRich Co-authored-by: Matheus Richard * Apply suggestion from @MatheusRich Co-authored-by: Matheus Richard * Apply suggestion from @MatheusRich Co-authored-by: Matheus Richard * Apply suggestion from @rafaelfranca Co-authored-by: Rafael Mendonça França * Apply suggestion from @rafaelfranca Co-authored-by: Rafael Mendonça França * Apply suggestion from @rafaelfranca Co-authored-by: Rafael Mendonça França * Apply suggestion from @rafaelfranca Co-authored-by: Rafael Mendonça França * Apply suggestion from @rafaelfranca Co-authored-by: Rafael Mendonça França * Apply suggestion from @rafaelfranca Co-authored-by: Rafael Mendonça França * Apply suggestion from @rafaelfranca Co-authored-by: Rafael Mendonça França * Apply suggestion from @rafaelfranca Co-authored-by: Rafael Mendonça França * Apply suggestion from @excid3 * Apply suggestion from @excid3 * Update tests * More consistent code tags when referencing models * Apply suggestions from code review Co-authored-by: Amanda Perino <58528404+AmandaPerino@users.noreply.github.com> * Apply suggestions from code review Co-authored-by: Amanda Perino <58528404+AmandaPerino@users.noreply.github.com> * Apply suggestions from code review Co-authored-by: Amanda Perino <58528404+AmandaPerino@users.noreply.github.com> * Two more paragraph tweaks * Clarify validation reference --------- Co-authored-by: Rafael Mendonça França Co-authored-by: Matheus Richard Co-authored-by: Amanda Perino <58528404+AmandaPerino@users.noreply.github.com> Co-authored-by: Rafael Mendonça França --- guides/source/wishlists.md | 1656 ++++++++++++++++++++++++++++++++++++ 1 file changed, 1656 insertions(+) create mode 100644 guides/source/wishlists.md diff --git a/guides/source/wishlists.md b/guides/source/wishlists.md new file mode 100644 index 0000000000000..2b8a323a90981 --- /dev/null +++ b/guides/source/wishlists.md @@ -0,0 +1,1656 @@ +**DO NOT READ THIS FILE ON GITHUB, GUIDES ARE PUBLISHED ON .** + +Wishlists +========= + +This guide covers adding Wishlists to the e-commerce application you created in the +[Getting Started Guide](getting_started.html)). We will use the code from the +[Sign up and Settings Guide](sign_up_and_settings.html) as a starting place. + +After reading this guide, you will know how to: + +* Add wishlists +* Use counter caches +* Add friendly URLs +* Filter records + +-------------------------------------------------------------------------------- + +Introduction +------------ + +E-commerce stores often have wishlists for sharing products. Customers can use +wishlists to keep track of products they'd like to buy or share them with +friends and family for gift ideas. + +Let's get started! + +Wishlist Models +--------------- + +Our e-commerce store has products and users that we already built in the previous +tutorials. These are the foundations we need to build Wishlists. Each wishlist +belongs to a user and contains a list of products. + +Let's start by creating the `Wishlist` model. + +```bash +$ bin/rails generate model Wishlist user:belongs_to name products_count:integer +``` + +This model has 3 attributes: + +- `user:belongs_to` which associates the `Wishlist` with the `User` who + owns it +- `name` which we'll also use for friendly URLs +- `products_count` for the [counter cache](https://guides.rubyonrails.org/association_basics.html#counter-cache) to count how many products + are on the Wishlist + +To associate a `Wishlist` with multiple `Products`, we need to add a table to +join them. + +```bash +$ bin/rails generate model WishlistProduct product:belongs_to wishlist:belongs_to +``` + +We don't want the same `Product` to be on a `Wishlist` multiple times, so let's +add an index to the migration that was just created: + +```ruby#10 +class CreateWishlistProducts < ActiveRecord::Migration[8.0] + def change + create_table :wishlist_products do |t| + t.belongs_to :product, null: false, foreign_key: true + t.belongs_to :wishlist, null: false, foreign_key: true + + t.timestamps + end + + add_index :wishlist_products, [:product_id, :wishlist_id], unique: true + end +end +``` + +Finally, let's add a counter to the `Product` model to keep track of how many +`Wishlists` the product is on. + +```bash +$ bin/rails generate migration AddWishlistsCountToProducts wishlists_count:integer +``` + +### Default Counter Cache Values + +Before we run these new migrations, let's set a default value for the counter +cache columns so that all existing records start with a count of zero instead of NULL. + +Open the `db/migrate/_create_wishlists.rb` migration and add the +default option: + +```ruby#6 +class CreateWishlists < ActiveRecord::Migration[8.0] + def change + create_table :wishlists do |t| + t.belongs_to :user, null: false, foreign_key: true + t.string :name + t.integer :products_count, default: 0 + + t.timestamps + end + end +end +``` + +Then open `db/migrate/_add_wishlists_count_to_products.rb` and add a +default here too: + +```ruby#3 +class AddWishlistsCountToProducts < ActiveRecord::Migration[8.0] + def change + add_column :products, :wishlists_count, :integer, default: 0 + end +end +``` + +Now let's run the migrations: + +```bash +$ bin/rails db:migrate +``` + +### Associations & Counter Caches + +Now that our database tables are created, let's update our models in Rails to +include these new associations. + +In `app/models/user.rb`, add the following: + +```ruby#4 +class User < ApplicationRecord + has_secure_password + has_many :sessions, dependent: :destroy + has_many :wishlists, dependent: :destroy + + # ... +``` + +We set `dependent: :destroy` on the `wishlists` association so when a User is +deleted, their wishlists are deleted too. + +Then in `app/models/product.rb`, add: + +```ruby#5-6 +class Product < ApplicationRecord + include Notifications + + has_many :subscribers, dependent: :destroy + has_many :wishlist_products, dependent: :destroy + has_many :wishlists, through: :wishlist_products + has_one_attached :featured_image + has_rich_text :description +``` + +We added two associations to `Product`. First, we associate the `Product` model +with the `WishlistProduct` join table. Using this join table, our second +association tells Rails that a `Product` is a part of many `Wishlists` through +the same `WishlistProduct` join table. From a `Product` record, we can directly +access the `Wishlists` and Rails will know to automatically `JOIN` the tables in +SQL queries. + +We also set `wishlist_products` as `dependent: :destroy`. When a `Product` is +destroyed, it will be automatically removed from any Wishlists. + +A counter cache stores the number of associated records to avoid running a separate query each time the count is needed. So in `app/models/wishlist.rb`, let's update both associations to enable counter +caching: + +```ruby#2-5 +class WishlistProduct < ApplicationRecord + belongs_to :product, counter_cache: :wishlists_count + belongs_to :wishlist, counter_cache: :products_count + + validates :product_id, uniqueness: { scope: :wishlist_id } +end +``` + +We've specified a column name to update on the associated models. For the +`Product` model, we want to use the `wishlists_count` column and for `Wishlist` we +want to use `products_count`. These counter caches update anytime a +`WishlistProduct` is created or destroyed. + +The `uniqueness` validation also tells Rails to check if a product is already on +the wishlist. This is paired with the unique index on the wishlist_product table +so that it's also validated at the database level. + +Finally, let's update `app/models/wishlist.rb` with it's associations: + +```ruby +class Wishlist < ApplicationRecord + belongs_to :user + has_many :wishlist_products, dependent: :destroy + has_many :products, through: :wishlist_products +end +``` + +Just like with `Product`, `wishlist_products` uses the `dependent: :destroy` +option to automatically remove join table records when a Wishlist is deleted. + +### Friendly URLs + +Wishlists are often shared with friends and family. By default, the ID in the +URL for a `Wishlist` is a simple Integer. This means we can't easily look at the +URL to determine which `Wishlist` it's for. + +Active Record has a `to_param` class method that can be used for generating more descriptive +URLs. Let's try it out in our model: + +```ruby#6-8 +class Wishlist < ApplicationRecord + belongs_to :user + has_many :wishlist_products, dependent: :destroy + has_many :products, through: :wishlist_products + + def to_param + "#{id}-#{name.squish.parameterize}" + end +end +``` + +This will create a `to_param` instance method that returns a String for the URL param made up of the `id` and `name` joined by +hyphens. `name` is made URL safe by using +[`squish`](https://api.rubyonrails.org/classes/String.html#method-i-squish) to +clean up whitespace and +[`parameterize`](https://api.rubyonrails.org/classes/String.html#method-i-parameterize) +to replace special characters. + +Let's test this in the Rails console: + +```bash +$ bin/rails console +``` + +Then create a `Wishlist` for your `User` in the database: + +```irb +store(dev)> user = User.first +store(dev)> wishlist = user.wishlists.create!(name: "Example Wishlist") +store(dev)> wishlist.to_param +=> "1-example-wishlist" +``` + +Perfect! + +Now let's try finding this record using this param: + +```irb +store(dev)> wishlist = Wishlist.find("1-example-wishlist") +=> # +``` + +It worked! But how? Didn't we have to use Integers to find records? + +The way we're using `to_param` takes advantage of [how Ruby converts Strings to +Integers](https://docs.ruby-lang.org/en/master/String.html#method-i-to_i). Let's convert that param to an integer using `to_i` in the console: + +```irb +store(dev)> "1-example-wishlist".to_i +=> 1 +``` + +Ruby parses the String until it finds a character that isn't a valid number. In +this case, it stops at the first hyphen. Then Ruby converts the String of `"1"` +into an Integer and returns `1`. This makes `to_param` work seamlessly when +prefixing the ID at the beginning. + +Now that we understand how this works, let's replace our `to_param` method with +a call to the class method shortcut. + +```ruby#6 +class Wishlist < ApplicationRecord + belongs_to :user + has_many :wishlist_products, dependent: :destroy + has_many :products, through: :wishlist_products + + to_param :name +end +``` + +The +[`to_param`](https://edgeapi.rubyonrails.org/classes/ActiveRecord/Integration/ClassMethods.html#method-i-to_param) +class method defines an instance method with the same name. The argument is the +method name to be called for generating the param. We're telling it to use the +`name` attribute to generate the param. + +One additional thing `to_param` does is truncate values longer than 20 +characters word by word. + +Let's reload our code in the Rails console and test out a long `Wishlist` name. + +```irb +store(dev)> reload! +store(dev)> Wishlist.last.update(name: "A really, really long wishlist name!") +store(dev)> Wishlist.last.to_param +=> "1-a-really-really-long" +``` + +You can see that the name was truncated to the closest word to 20 characters. + +Alright, close the Rails console and let's start implementing wishlists in the UI. + +## Adding Products To Wishlists + +The first place a user will probably use wishlists is on the `Product` show page. +They'll likely be browsing products and want to save one for later. Let's begin +by building that first. + +### Add To Wishlist Form + +Start in `config/routes.rb` by adding the route for this form to submit to: + +```ruby#2 + resources :products do + resource :wishlist, only: [ :create ], module: :products + resources :subscribers, only: [ :create ] + end +``` + +We're using a singular resource for this route since we won't necessarily know +the Wishlist ID ahead of time. We're also using `module: :products` to scope +this controller to the `Products` namespace. + +In `app/views/products/show.html.erb`, add the following to render a new +wishlist partial: + +```erb#13 +

<%= link_to "Back", products_path %>

+ +
+ <%= image_tag @product.featured_image if @product.featured_image.attached? %> + +
+ <% cache @product do %> +

<%= @product.name %>

+ <%= @product.description %> + <% end %> + + <%= render "inventory", product: @product %> + <%= render "wishlist", product: @product %> +
+
+``` + +Then create `app/views/products/_wishlist.html.erb` with the following: + +```erb +<% if authenticated? %> + <%= form_with url: product_wishlist_path(product) do |form| %> +
+ <%= form.collection_select :wishlist_id, Current.user.wishlists, :id, :name %> +
+ +
+ <%= form.submit "Add to wishlist" %> +
+ <% end %> +<% else %> + <%= link_to "Add to wishlist", sign_up_path %> +<% end %> +``` + +If a user is not logged in, they'll see a link to sign up. Logged in users will +see a form to select a wishlist and add the product to it. + +Next, create the controller to handle this form in +`app/controllers/products/wishlists_controller.rb` with the following: + +```ruby +class Products::WishlistsController < ApplicationController + before_action :set_product + before_action :set_wishlist + + def create + @wishlist.wishlist_products.create(product: @product) + redirect_to @wishlist, notice: "#{@product.name} added to wishlist." + end + + private + def set_product + @product = Product.find(params[:product_id]) + end + + def set_wishlist + @wishlist = Current.user.wishlists.find(params[:wishlist_id]) + end +end +``` + +Since we're in a nested resource route, we find the `Product` using the +`:product_id` param. + +The `create` action is also simpler than normal. If a product is already on the +wishlist, the `wishlist_product` record will fail to create but we don't need to +notify the user of this error so we can redirect to the wishlist in either case. + +Now, log in as the user we created a wishlist for earlier and try adding a product to the +wishlist. + +### Default Wishlist + +This works fine since we created a wishlist in the Rails console, but what +happens when the user doesn't have any wishlists? + +Run the following to delete all wishlists in the database: + +```bash +$ bin/rails runner "Wishlist.destroy_all" +``` + +Try visiting a product and adding it to a wishlist now. + +The first problem is the select box will be empty. The form will not submit a +`wishlist_id` param to the server and that will cause Active Record to raise an +error. + +```bash +ActiveRecord::RecordNotFound (Couldn't find Wishlist without an ID): + +app/controllers/products/wishlists_controller.rb:16:in 'Products::WishlistsController#set_wishlist' +``` + +In this case, we should automatically create a wishlist if the user doesn't have +any. This has the added bonus of slowly introducing the user to wishlists. + +Update `set_wishlist` in the controller to find or create a wishlist: + +```ruby#16-20 +class Products::WishlistsController < ApplicationController + before_action :set_product + before_action :set_wishlist + + def create + @wishlist.wishlist_products.create(product: @product) + redirect_to @wishlist, notice: "#{@product.name} added to wishlist." + end + + private + def set_product + @product = Product.find(params[:product_id]) + end + + def set_wishlist + if (id = params[:wishlist_id]) + @wishlist = Current.user.wishlists.find(id) + else + @wishlist = Current.user.wishlists.create(name: "My Wishlist") + end + end +end +``` + +To improve our form, let's hide the select box if the user doesn't have any +wishlists. Update `app/views/products/_wishlist.html.erb` with the following: + +```erb#3,7 +<% if authenticated? %> + <%= form_with url: product_wishlist_path(product) do |form| %> + <% if Current.user.wishlists.any? %> +
+ <%= form.collection_select :wishlist_id, Current.user.wishlists, :id, :name %> +
+ <% end %> + +
+ <%= form.submit "Add to wishlist" %> +
+ <% end %> +<% else %> + <%= link_to "Add to wishlist", sign_up_path %> +<% end %> +``` + +## Managing Wishlists + +Next, we need to be able to view and manage our wishlists. + +### Wishlists Controller + +Start by adding a route for wishlists at the top level: + +```ruby#9 +Rails.application.routes.draw do + # ... + resources :products do + resource :wishlist, only: [ :create ], module: :products + resources :subscribers, only: [ :create ] + end + resource :unsubscribe, only: [ :show ] + + resources :wishlists +``` + +Then we can add the controller at `app/controllers/wishlists_controller.rb` with +the following: + +```ruby +class WishlistsController < ApplicationController + allow_unauthenticated_access only: %i[ show ] + before_action :set_wishlist, only: %i[ edit update destroy ] + + def index + @wishlists = Current.user.wishlists + end + + def show + @wishlist = Wishlist.find(params[:id]) + end + + def new + @wishlist = Wishlist.new + end + + def create + @wishlist = Current.user.wishlists.new(wishlist_params) + if @wishlist.save + redirect_to @wishlist, notice: "Your wishlist was created successfully." + else + render :new, status: :unprocessable_entity + end + end + + def edit + end + + def update + if @wishlist.update(wishlist_params) + redirect_to @wishlist, status: :see_other, notice: "Your wishlist has been updated successfully." + else + render :edit, status: :unprocessable_entity + end + end + + def destroy + @wishlist.destroy + redirect_to wishlists_path, status: :see_other + end + + private + + def set_wishlist + @wishlist = Current.user.wishlists.find(params[:id]) + end + + def wishlist_params + params.expect(wishlist: [ :name ]) + end +end +``` + +This is a very standard controller with a couple important changes: + +- Actions are scoped to `Current.user.wishlists` so only the owner can create, + update, and delete their own wishlists +- `show` is publicly accessible so wishlists can be shared and viewed by anyone + +### Wishlist Views + +Create the index view at `app/views/wishlists/index.html.erb`: + +```erb +

Your Wishlists

+<%= link_to "Create a wishlist", new_wishlist_path %> +<%= render @wishlists %> +``` + +This renders the `_wishlist` partial so let's create that at +`app/views/wishlists/_wishlist.html.erb`: + +```erb +
+ <%= link_to wishlist.name, wishlist %> +
+``` + +Next let's create the `new` view at `app/views/wishlists/new.html.erb`: + +```erb +

New Wishlist

+<%= render "form", locals: { wishlist: @wishlist } %> +``` + +And the `edit` view at `app/views/wishlists/edit.html.erb`: + +```erb +

Edit Wishlist

+<%= render "form", locals: { wishlist: @wishlist } %> +``` + +Along with the `_form` partial at `app/views/wishlists/_form.html.erb`: + +```erb +<%= form_with model: @wishlist do |form| %> + <% if form.object.errors.any? %> +
<%= form.object.errors.full_messages.to_sentence %>
+ <% end %> + +
+ <%= form.label :name %> + <%= form.text_field :name %> +
+ +
+ <%= form.submit %> + <%= link_to "Cancel", form.object.persisted? ? form.object : wishlists_path %> +
+<% end %> +``` + +Create `show` next at `app/views/wishlists/show.html.erb`: + +```erb +

<%= @wishlist.name %>

+<% if authenticated? && @wishlist.user == Current.user %> + <%= link_to "Edit", edit_wishlist_path(@wishlist) %> + <%= button_to "Delete", @wishlist, method: :delete, data: { turbo_confirm: "Are you sure?" } %> +<% end %> + +

<%= pluralize @wishlist.products_count, "Product" %>

+<% @wishlist.wishlist_products.includes(:product).each do %> +
+ <%= link_to it.product.name, it.product %> + Added <%= l it.created_at, format: :long %> +
+<% end %> +``` + +Lastly, let's add a link to the navbar in +`app/views/layouts/application.html.erb`: + +```erb#4 + +``` + +Refresh the page and click the "Wishlists" link in the navbar to view and manage your +wishlists. + +### Copy To Clipboard + +To make sharing wishlists easier, we can add a “Copy to Clipboard” button that uses a small amount of JavaScript. + +Rails includes Hotwire by default, so we can use its [Stimulus framework](https://stimulus.hotwired.dev/) + to add some lightweight JavaScript to our UI. + +First, let's add a button to `app/views/wishlists/show.html.erb`: + +```erb#7 +

<%= @wishlist.name %>

+<% if authenticated? && @wishlist.user == Current.user %> + <%= link_to "Edit", edit_wishlist_path(@wishlist) %> + <%= button_to "Delete", @wishlist, method: :delete, data: { turbo_confirm: "Are you sure?" } %> +<% end %> + +<%= tag.button "Copy to clipboard", data: { controller: :clipboard, action: "clipboard#copy", clipboard_text_value: wishlist_url(@wishlist) } %> +``` + +This button has several data attributes that wire up to the JavaScript. We're +using the Rails `tag` helper to make this shorter which outputs the following +HTML: + +```html + +``` + +What do these data attributes do? Let's break down each one: + +- `data-controller` tells Stimulus to connect to `clipboard_controller.js` +- `data-action` tells Stimulus to call the + `clipboard` controller's `copy()` method when the button is clicked +- `data-clipboard-text-value` tells the Stimulus controller it has some data + called `text` that it can use + +Create the Stimulus controller at +`app/javascript/controllers/clipboard_controller.js`: + +```javascript +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static values = { text: String } + + copy() { + navigator.clipboard.writeText(this.textValue) + } +} +``` + +This Stimulus controller is short. It does two things: + +- Registers `text` as a value so we can access it. This is the URL we want to + copy to the clipboard. +- The `copy` function writes the `text` from the HTML to the clipboard when + called. + +If you're familiar with JavaScript, you'll notice we didn't have to add any event listeners or setup & teardown this +controller. That's handled automatically by Stimulus reading the data attributes +in our HTML. + +To learn more about Stimulus, check out the +[Stimulus](https://stimulus.hotwired.dev/) website. + +### Removing Products + +A user may purchase or lose interest in a product and want to remove it from +their wishlist. Let's add that feature next. + +First we'll update the wishlists route to contain a nested resource. + +```ruby#9-11 +Rails.application.routes.draw do + # ... + resources :products do + resource :wishlist, only: [ :create ], module: :products + resources :subscribers, only: [ :create ] + end + resource :unsubscribe, only: [ :show ] + + resources :wishlists do + resources :wishlist_products, only: [ :update, :destroy ], module: :wishlists + end +``` + +Then we can update `app/views/wishlists/show.html.erb` to include a "Remove" +button: + +```erb#13-15 +

<%= @wishlist.name %>

+<% if authenticated? && @wishlist.user == Current.user %> + <%= link_to "Edit", edit_wishlist_path(@wishlist) %> + <%= button_to "Delete", @wishlist, method: :delete, data: { turbo_confirm: "Are you sure?" } %> +<% end %> + +

<%= pluralize @wishlist.products_count, "Product" %>

+<% @wishlist.wishlist_products.includes(:product).each do %> +
+ <%= link_to it.product.name, it.product %> + Added <%= l it.created_at, format: :long %> + + <% if authenticated? && @wishlist.user == Current.user %> + <%= button_to "Remove", [ @wishlist, it ], method: :delete, data: { turbo_confirm: "Are you sure?" } %> + <% end %> +
+<% end %> +``` + +Create `app/controllers/wishlists/wishlist_products_controller.rb` and add the +following: + +```ruby +class Wishlists::WishlistProductsController < ApplicationController + before_action :set_wishlist + before_action :set_wishlist_product + + def destroy + @wishlist_product.destroy + redirect_to @wishlist, notice: "#{@wishlist_product.product.name} removed from wishlist." + end + + private + + def set_wishlist + @wishlist = Current.user.wishlists.find_by(id: params[:wishlist_id]) + end + + def set_wishlist_product + @wishlist_product = @wishlist.wishlist_products.find(params[:id]) + end +end +``` + +You can now remove products from any wishlist. Try it out! + +### Moving Products To Another Wishlist + +With multiple wishlists, users may want to move a product from one list to +another. For example, they might want to move items into a "Christmas" wishlist. + +In `app/views/wishlists/show.html.erb`, add the following: + +```erb#14-19 +

<%= @wishlist.name %>

+<% if authenticated? && @wishlist.user == Current.user %> + <%= link_to "Edit", edit_wishlist_path(@wishlist) %> + <%= button_to "Delete", @wishlist, method: :delete, data: { turbo_confirm: "Are you sure?" } %> +<% end %> + +

<%= pluralize @wishlist.products_count, "Product" %>

+<% @wishlist.wishlist_products.includes(:product).each do %> +
+ <%= link_to it.product.name, it.product %> + Added <%= l it.created_at, format: :long %> + + <% if authenticated? && @wishlist.user == Current.user %> + <% if (other_wishlists = Current.user.wishlists.excluding(@wishlist)) && other_wishlists.any? %> + <%= form_with url: [ @wishlist, it ], method: :patch do |form| %> + <%= form.collection_select :new_wishlist_id, other_wishlists, :id, :name %> + <%= form.submit "Move" %> + <% end %> + <% end %> + + <%= button_to "Remove", [ @wishlist, it ], method: :delete, data: { turbo_confirm: "Are you sure?" } %> + <% end %> +
+<% end %> +``` + +This queries for other wishlists and, if present, renders a form to move a +product to the selected wishlist. If no other wishlists exist, the form will not +be displayed. + +To handle this in the controller, we'll add the `update` action to +`app/controllers/wishlists/wishlist_products_controller.rb`: + +```ruby#5-12 +class Wishlists::WishlistProductsController < ApplicationController + before_action :set_wishlist + before_action :set_wishlist_product + + def update + new_wishlist = Current.user.wishlists.find(params[:new_wishlist_id]) + if @wishlist_product.update(wishlist: new_wishlist) + redirect_to @wishlist, status: :see_other, notice: "#{@wishlist_product.product.name} has been moved to #{new_wishlist.name}" + else + redirect_to @wishlist, status: :see_other, alert: "#{@wishlist_product.product.name} is already on #{new_wishlist.name}." + end + end + + # ... +``` + +This action looks up the new wishlist from the logged in user's wishlists. It +then tries to update the wishlist ID on `@wishlist_product`. This could fail if +the product already exists on the other wishlist so we'll display an error in +that case. If not, we can simply transfer the product to the new wishlist. Since +we don't want the user to lose their place, we redirect back to the current +wishlist they're viewing in either case. + +Test this out by creating a second wishlist and moving a product back and forth. + +## Adding Wishlists To Admin + +Viewing wishlists in the admin area will be helpful to get an idea of which +products are popular. + +To start, let's add wishlists to the store namespace routes in +`config/routes.rb`: + +```ruby#5 + # Admins Only + namespace :store do + resources :products + resources :users + resources :wishlists + + root to: redirect("/store/products") + end +``` + +Create `app/controllers/store/wishlists_controller.rb` with: + +```ruby +class Store::WishlistsController < Store::BaseController + def index + @wishlists = Wishlist.includes(:user) + end + + def show + @wishlist = Wishlist.find(params[:id]) + end +end +``` + +We only need the index and show actions here because as admins, we don't want to +mess with user's wishlists. + +Now let's add the views for these actions. +Create `app/views/store/wishlists/index.html.erb` with: + +```erb +

Wishlists

+<%= render @wishlists %> +``` + +Then create the wishlist partial in +`app/views/store/wishlists/_wishlist.html.erb` with: + +```erb +
+ <%= link_to wishlist.name, store_wishlist_path(wishlist) %> by <%= link_to wishlist.user.full_name, store_user_path(wishlist.user) %> +
+``` + +Then create the show view at `app/views/store/wishlists/show.html.erb` with: + +```erb +

<%= @wishlist.name %>

+

By <%= link_to @wishlist.user.full_name, store_user_path(@wishlist.user) %>

+ +

<%= pluralize @wishlist.products_count, "Product" %>

+<% @wishlist.wishlist_products.includes(:product).each do %> +
+ <%= link_to it.product.name, store_product_path(it.product) %> + Added <%= l it.created_at, format: :long %> +
+<% end %> +``` + +Lastly, add the link to the sidebar layout: + +```erb#14 +<%= content_for :content do %> +
+ + +
+ <%= yield %> +
+
+<% end %> + +<%= render template: "layouts/application" %> +``` + +Now we can view wishlists in the admin area. + +### Filtering Wishlists + +To get a better look at data in the admin area, it's helpful to have filters. We +can filter wishlists by user or by product. + +Update `app/views/store/wishlists/index.html.erb` by adding the following form: + +```erb#1,3-7 +

<%= pluralize @wishlists.count, "Wishlist" %>

+ +<%= form_with url: store_wishlists_path, method: :get do |form| %> + <%= form.collection_select :user_id, User.all, :id, :full_name, selected: params[:user_id], include_blank: "All Users" %> + <%= form.collection_select :product_id, Product.all, :id, :name, selected: params[:product_id], include_blank: "All Products" %> + <%= form.submit "Filter" %> +<% end %> + +<%= render @wishlists %> +``` + +We've updated the header to show the total number of wishlists, which makes it +easier to see how many results match when a filter is applied. When you submit +the form, Rails adds your selected filters to the URL as query params. The form +then reads those values when loading the page to automatically re-select the +same options in the dropdowns, so your choices stay visible after submitting. +Since the form submits to the index action, so it can display either all +wishlists or just the filtered results. + +To make this work, we need to apply these filters in our SQL query with +Active Record. Update the controller to include these filters: + +```ruby#4-5 +class Store::WishlistsController < Store::BaseController + def index + @wishlists = Wishlist.includes(:user) + @wishlists = @wishlists.where(user_id: params[:user_id]) if params[:user_id].present? + @wishlists = @wishlists.includes(:wishlist_products).where(wishlist_products: { product_id: params[:product_id] }) if params[:product_id].present? + end + + def show + @wishlist = Wishlist.find(params[:id]) + end +end +``` + +Active Record queries are _lazy evaluated_ which means SQL queries aren't executed +until you ask for the results. This allows our controller to build up the query +step-by-step and include filters if needed. + +Once you have more wishlists in the system, you can use the filters to view +wishlists by a specific user, product, or a combination of both. + +### Refactoring Filters + +Our controller has gotten a bit messy by introducing these filters. Let's move +our logic out of the controller by extracting a method on the `Wishlist` model. + +```ruby#3 +class Store::WishlistsController < Store::BaseController + def index + @wishlists = Wishlist.includes(:user).filter_by(params) + end + + def show + @wishlist = Wishlist.find(params[:id]) + end +end +``` + +We'll implement `filter_by` in the `Wishlist` model by defining a class method. + +```ruby#8-13 +class Wishlist < ApplicationRecord + belongs_to :user + has_many :wishlist_products, dependent: :destroy + has_many :products, through: :wishlist_products + + to_param :name + + def self.filter_by(params) + results = all + results = results.where(user_id: params[:user_id]) if params[:user_id].present? + results = results.includes(:wishlist_products).where(wishlist_products: {product_id: params[:product_id]}) if params[:product_id].present? + results + end +end +``` + +`filter_by` is almost the same as what we had in the controller, but we start by +calling +[`all`](https://api.rubyonrails.org/classes/ActiveRecord/Scoping/Named/ClassMethods.html#method-i-all) +which returns an `ActiveRecord::Relation` for all the records including any +conditions we may have already applied. Then we apply the filters and return the +results. + +Refactoring like this means the controller becomes cleaner, while the filtering logic now lives in the model where it belongs, alongside other database-related logic. This follows the **Fat Model, Skinny Controller** principle, a best practice in Rails. + +## Adding Subscribers To Admin + +While we're here, we should also add the ability to view and filter subscribers in the +admin too. This is helpful to know how many people are waiting for a product to +go back in stock. + +### Subscriber Views + +First, we'll add the subscribers route to the `store` namespace: + +```ruby#6 + # Admins Only + namespace :store do + resources :products + resources :users + resources :wishlists + resources :subscribers + + root to: redirect("/store/products") + end +``` + +Then, let's create the controller at +`app/controllers/store/subscribers_controller.rb`: + +```ruby +class Store::SubscribersController < Store::BaseController + before_action :set_subscriber, except: [ :index ] + + def index + @subscribers = Subscriber.includes(:product).filter_by(params) + end + + def show + end + + def destroy + @subscriber.destroy + redirect_to store_subscribers_path, notice: "Subscriber has been removed.", status: :see_other + end + + private + def set_subscriber + @subscriber = Subscriber.find(params[:id]) + end +end +``` + +We've only implemented `index`, `show`, and `destroy` actions here. Subscribers +will only be created when a user enters their email address. If someone contacts +support asking to unsubscribe them, we want to be able to remove them easily. + +Since this is the admin area, we will want to add filters to subscribers too. + +In `app/models/subscriber.rb`, let's add the `filter_by` class method: + +```ruby +class Subscriber < ApplicationRecord + belongs_to :product + generates_token_for :unsubscribe + + def self.filter_by(params) + results = all + results = results.where(product_id: params[:product_id]) if params[:product_id].present? + results + end +end +``` + +Let's create the index view next at +`app/views/store/subscribers/index.html.erb`: + +```erb +

<%= pluralize "Subscriber", @subscribers.count %>

+ +<%= form_with url: store_subscribers_path, method: :get do |form| %> + <%= form.collection_select :product_id, Product.all, :id, :name, selected: params[:product_id], include_blank: "All Products" %> + <%= form.submit "Filter" %> +<% end %> + +<%= render @subscribers %> +``` + +Then create `app/views/store/subscribers/_subscriber.html.erb` for displaying +each subscriber: + +```erb +
+ <%= link_to subscriber.email, store_subscriber_path(subscriber) %> subscribed to <%= link_to subscriber.product.name, store_product_path(subscriber.product) %> on <%= l subscriber.created_at, format: :long %> +
+``` + +Next, create `app/views/store/subscribers/show.html.erb` to view an individual +subscriber: + +```erb +

<%= @subscriber.email %>

+

Subscribed to <%= link_to @subscriber.product.name, store_product_path(@subscriber.product) %> on <%= l @subscriber.created_at, format: :long %>

+ +<%= button_to "Remove", store_subscriber_path(@subscriber), method: :delete, data: { turbo_confirm: "Are you sure?" } %> +``` + +Finally, add the link to the sidebar layout: + +```erb#14 +<%= content_for :content do %> +
+ + +
+ <%= yield %> +
+
+<% end %> + +<%= render template: "layouts/application" %> +``` + +Now you can view, filter, and remove subscribers in the store's admin area. Try +it out! + +## Adding Links To Products + +Now that we've added filters, we can add links to the Product show page for +viewing wishlists and subscribers for a specific product. + +Open `app/views/store/products/show.html.erb` and add the links: + +```erb#18-21 +

<%= link_to "Back", store_products_path %>

+ +
+ <%= image_tag @product.featured_image if @product.featured_image.attached? %> + +
+ <% cache @product do %> +

<%= @product.name %>

+ <%= @product.description %> + <% end %> + + <%= link_to "View in Storefront", @product %> + <%= link_to "Edit", edit_store_product_path(@product) %> + <%= button_to "Delete", [ :store, @product ], method: :delete, data: { turbo_confirm: "Are you sure?" } %> +
+
+ +
+ <%= link_to pluralize(@product.wishlists_count, "wishlist"), store_wishlists_path(product_id: @product) %> + <%= link_to pluralize(@product.subscribers.count, "subscriber"), store_subscribers_path(product_id: @product) %> +
+``` + +## Testing Wishlists + +Let's write some tests for the functionality we just built. + +### Adding Fixtures + +First, we need to update the fixtures in `test/fixtures/wishlist_products.yml` +so they refer to the product fixtures we have defined: + +```yaml +one: + product: tshirt + wishlist: one + +two: + product: tshirt + wishlist: two +``` + +Let's also add another `Product` fixture in `test/fixtures/products.yml` to test +with: + +```yaml#5-7 +tshirt: + name: T-Shirt + inventory_count: 15 + +shoes: + name: shoes + inventory_count: 0 +``` + +### Testing `filter_by` + +The `Wishlist` model's `filter_by` method is important to ensure it's filtering +records correctly. + +Open `test/models/wishlist_test.rb` and add this test to start: + +```ruby +require "test_helper" + +class WishlistTest < ActiveSupport::TestCase + test "filter_by with no filters" do + assert_equal Wishlist.all, Wishlist.filter_by({}) + end +end +``` + +This test ensures that `filter_by` returns all records when no filters are +applied. + +Then run the test: + +```bash +$ bin/rails test test/models/wishlist_test.rb +Running 1 tests in a single process (parallelization threshold is 50) +Run options: --seed 64578 + +# Running: + +. + +Finished in 0.290295s, 3.4448 runs/s, 3.4448 assertions/s. +1 runs, 1 assertions, 0 failures, 0 errors, 0 skips +``` + +Great! Next, we need to test the `user_id` filter. Let's add another test: + +```ruby#8-12 +require "test_helper" + +class WishlistTest < ActiveSupport::TestCase + test "filter_by with no filters" do + assert_equal Wishlist.all, Wishlist.filter_by({}) + end + + test "filter_by with user_id" do + wishlists = Wishlist.filter_by(user_id: users(:one).id) + assert_includes wishlists, wishlists(:one) + assert_not_includes wishlists, wishlists(:two) + end +end +``` + +This test runs the query and asserts the wishlist for the user is returned but +not wishlists for another user. + +Let's run the test file again: + +```bash +$ bin/rails test test/models/wishlist_test.rb +Running 2 tests in a single process (parallelization threshold is 50) +Run options: --seed 48224 + +# Running: + +.. + +Finished in 0.292714s, 6.8326 runs/s, 17.0815 assertions/s. +2 runs, 5 assertions, 0 failures, 0 errors, 0 skips +``` + +Perfect! Both tests are passing. + +Finally, let's add a test for wishlists with a specific product. + +For this test, we need to add a unique product to one of our wishlists so it can +be filtered. + +Open `test/fixtures/wishlist_products.yml` and add the following: + +```yaml#9-11 +one: + product: tshirt + wishlist: one + +two: + product: tshirt + wishlist: two + +three: + product: shoes + wishlist: two +``` + +Then add the following test to `test/models/wishlist_test.rb`: + +```ruby +require "test_helper" + +class WishlistTest < ActiveSupport::TestCase + test "filter_by with no filters" do + assert_equal Wishlist.all, Wishlist.filter_by({}) + end + + test "filter_by with user_id" do + wishlists = Wishlist.filter_by(user_id: users(:one).id) + assert_includes wishlists, wishlists(:one) + assert_not_includes wishlists, wishlists(:two) + end + + test "filter_by with product_id" do + wishlists = Wishlist.filter_by(product_id: products(:shoes).id) + assert_includes wishlists, wishlists(:two) + assert_not_includes wishlists, wishlists(:one) + end +end +``` + +This test filters by a specific product and ensures the correct wishlist is +returned and wishlists without that product are not. + +Let's run this test file again to ensure they are all passing: + +```ruby +bin/rails test test/models/wishlist_test.rb +Running 3 tests in a single process (parallelization threshold is 50) +Run options: --seed 27430 + +# Running: + +... + +Finished in 0.320054s, 9.3734 runs/s, 28.1203 assertions/s. +3 runs, 9 assertions, 0 failures, 0 errors, 0 skips +``` + +### Testing Wishlist CRUD + +Let's walk through writing some integration tests for wishlists. + +Create `test/integration/wishlists_test.rb` and add a test for creating a +wishlist. + +```ruby +require "test_helper" + +class WishlistsTest < ActionDispatch::IntegrationTest + test "create a wishlist" do + user = users(:one) + sign_in_as user + assert_difference "user.wishlists.count" do + post wishlists_path, params: { wishlist: { name: "Example" } } + assert_response :redirect + end + end +end +``` + +This test logs in as a user and makes a POST request to create a wishlist. It +checks the user's wishlists count before and after to ensure a new record was +created. It also confirms the user is redirected instead of re-rendering the +form with errors. + +Let's run this test and make sure it passes. + +```bash +$ bin/rails test test/integration/wishlists_test.rb +Running 1 tests in a single process (parallelization threshold is 50) +Run options: --seed 40232 + +# Running: + +. + +Finished in 0.603018s, 1.6583 runs/s, 4.9750 assertions/s. +1 runs, 3 assertions, 0 failures, 0 errors, 0 skips +``` + +Next, let's add a test for deleting a wishlist. + +```ruby +test "delete a wishlist" do + user = users(:one) + sign_in_as user + assert_difference "user.wishlists.count", -1 do + delete wishlist_path(user.wishlists.first) + assert_redirected_to wishlists_path + end +end +``` + +This test is similar to creating wishlists, but it asserts that there is one +less wishlist after making the DELETE request. + +Next, we should test viewing wishlists, starting with a user viewing their own +wishlist. + +```ruby +test "view a wishlist" do + user = users(:one) + wishlist = user.wishlists.first + sign_in_as user + get wishlist_path(wishlist) + assert_response :success + assert_select "h1", text: wishlist.name +end +``` + +A user should also be able to view other user's wishlists, so let's test that: + +```ruby +test "view a wishlist as another user" do + wishlist = wishlists(:two) + sign_in_as users(:one) + get wishlist_path(wishlist) + assert_response :success + assert_select "h1", text: wishlist.name +end +``` + +And guests should be able to view wishlists too: + +```ruby +test "view a wishlist as a guest" do + wishlist = wishlists(:one) + get wishlist_path(wishlist) + assert_response :success + assert_select "h1", text: wishlist.name +end +``` + +Let's run these tests and make sure they all pass: + +```bash +$ bin/rails test test/integration/wishlists_test.rb +Running 5 tests in a single process (parallelization threshold is 50) +Run options: --seed 43675 + +# Running: + +..... + +Finished in 0.645956s, 7.7405 runs/s, 13.9328 assertions/s. +5 runs, 9 assertions, 0 failures, 0 errors, 0 skips +``` + +Excellent! + +### Testing Wishlist Products + +Next, let's test products in wishlists. The best place to start is probably +adding a product to a wishlist. + +Add the following test to `test/integration/wishlists_test.rb`: + +```ruby +test "add product to a specific wishlist" do + sign_in_as users(:one) + wishlist = wishlists(:one) + assert_difference "WishlistProduct.count" do + post product_wishlist_path(products(:shoes)), params: { wishlist_id: wishlist.id } + assert_redirected_to wishlist + end +end +``` + +This test asserts that a new `WishlistProduct` record is created when we send a +POST request that simulates submitting the "Add to wishlist" form with a +selected wishlist. + +Next, let's test the case where a user has no wishlists. + +```ruby +test "add product when no wishlists" do + user = users(:one) + sign_in_as user + user.wishlists.destroy_all + assert_difference "Wishlist.count" do + assert_difference "WishlistProduct.count" do + post product_wishlist_path(products(:shoes)) + end + end +end +``` + +In this test, we delete all the user's wishlists to remove any wishlists that +may be present from fixtures. In addition to asserting a new `WishlistProduct` +was created, we also make sure a new `Wishlist` was created this time. + +We should also test that we can't add products to another user's wishlist. Add +the following test. + +```ruby +test "cannot add product to another user's wishlist" do + sign_in_as users(:one) + assert_no_difference "WishlistProduct.count" do + post product_wishlist_path(products(:shoes)), params: { wishlist_id: wishlists(:two).id } + assert_response :not_found + end +end +``` + +In this case, we sign in as one user and `POST` with the ID of a wishlist from +another user. To ensure this is working correctly, we assert that no new +`WishlistProduct` records were created and we also make sure the response was a +404 Not Found. + +Now, let's test moving products between wishlists. + +```ruby +test "move product to another wishlist" do + user = users(:one) + sign_in_as user + wishlist = user.wishlists.first + wishlist_product = wishlist.wishlist_products.first + second_wishlist = user.wishlists.create!(name: "Second Wishlist") + patch wishlist_wishlist_product_path(wishlist, wishlist_product), params: { new_wishlist_id: second_wishlist.id } + assert_equal second_wishlist, wishlist_product.reload.wishlist +end +``` + +This test has a bit more setup than the others. It creates a second wishlist to +move the product to. Since this action updates the `wishlist_id` column of the +`WishlistProduct` record, we save it to a variable and assert that it changes +after the request completes. + +We have to call `wishlist_product.reload` since the copy of the record in memory +is unaware of changes that happened during the request. This reloads the record +from the database so we can see the new values. + +Next, let's test moving a product to a wishlist that already contains the +product. In this case, we should get an error message and the `WishlistProduct` +should have no changes. + +```ruby + test "cannot move product to a wishlist that already contains product" do + user = users(:one) + sign_in_as user + wishlist = user.wishlists.first + wishlist_product = wishlist.wishlist_products.first + second_wishlist = user.wishlists.create!(name: "Second") + second_wishlist.wishlist_products.create(product_id: wishlist_product.product_id) + patch wishlist_wishlist_product_path(wishlist, wishlist_product), params: { new_wishlist_id: second_wishlist.id } + assert_equal "T-Shirt is already on Second Wishlist.", flash[:alert] + assert_equal wishlist, wishlist_product.reload.wishlist + end +``` + +This test uses an assertion against `flash[:alert]` to check for the error +message. It also reloads `wishlist_product` to assert that the wishlist has not +changed. + +Finally, we should add a test to ensure a user cannot move a product to another +user's wishlist. + +```ruby + test "cannot move product to another user's wishlist" do + user = users(:one) + sign_in_as user + wishlist = user.wishlists.first + wishlist_product = wishlist.wishlist_products.first + patch wishlist_wishlist_product_path(wishlist, wishlist_product), params: { new_wishlist_id: wishlists(:two).id } + assert_response :not_found + assert_equal wishlist, wishlist_product.reload.wishlist + end +``` + +In this case, we assert that the response was a 404 Not Found which shows that +we safely scoped the `new_wishlist_id` to the current user. + +It also asserts that the wishlist did not change, just like the previous test. + +Alright, let's run this full set of tests to double check they all pass. + +```bash +$ bin/rails test test/integration/wishlists_test.rb +Running 11 tests in a single process (parallelization threshold is 50) +Run options: --seed 65170 + +# Running: + +........... + +Finished in 1.084135s, 10.1463 runs/s, 23.0599 assertions/s. +11 runs, 25 assertions, 0 failures, 0 errors, 0 skips +``` + +Fantastic! Our tests are all passing. + +## Deploying To Production + +Since we previously setup Kamal in the +[Getting Started Guide](getting_started.html), we just need to push our code +changes to our Git repository and run: + +```bash +$ bin/kamal deploy +``` + +## What's Next + +Your e-commerce store now has Wishlists and an improved admin area with +filtering of Wishlists and Subscribers. + +Here are a few ideas to build on to this: + +- Add product reviews +- Write more tests +- Add payments to buy products + +[Return to all tutorials](https://rubyonrails.org/docs/tutorials) From 7e8fa100fae0b952e5792592d55c0e8dad77899c Mon Sep 17 00:00:00 2001 From: zzak Date: Sun, 5 Oct 2025 08:31:52 +0900 Subject: [PATCH 0787/1075] Add structured event for Rails deprecations, when `config.active_support.deprecation` is set to `:notify`. --- railties/CHANGELOG.md | 4 ++ .../lib/rails/structured_event_subscriber.rb | 18 ++++++++ .../test/structured_event_subscriber_test.rb | 42 +++++++++++++++++++ 3 files changed, 64 insertions(+) create mode 100644 railties/lib/rails/structured_event_subscriber.rb create mode 100644 railties/test/structured_event_subscriber_test.rb diff --git a/railties/CHANGELOG.md b/railties/CHANGELOG.md index f866a8e6570d9..2c34fde89cc1c 100644 --- a/railties/CHANGELOG.md +++ b/railties/CHANGELOG.md @@ -1,3 +1,7 @@ +* Add structured event for Rails deprecations, when `config.active_support.deprecation` is set to `:notify`. + + *zzak* + * Report unhandled exceptions to the Error Reporter when running rake tasks via Rails command. *Akimichi Tanei* diff --git a/railties/lib/rails/structured_event_subscriber.rb b/railties/lib/rails/structured_event_subscriber.rb new file mode 100644 index 0000000000000..9b62ed6e6ea87 --- /dev/null +++ b/railties/lib/rails/structured_event_subscriber.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require "active_support/structured_event_subscriber" + +module Rails + class StructuredEventSubscriber < ActiveSupport::StructuredEventSubscriber # :nodoc: + def deprecation(event) + emit_event("rails.deprecation", + message: event.payload[:message], + callstack: event.payload[:callstack], + gem_name: event.payload[:gem_name], + deprecation_horizon: event.payload[:deprecation_horizon], + ) + end + end +end + +Rails::StructuredEventSubscriber.attach_to :rails diff --git a/railties/test/structured_event_subscriber_test.rb b/railties/test/structured_event_subscriber_test.rb new file mode 100644 index 0000000000000..9b357a90b2b1f --- /dev/null +++ b/railties/test/structured_event_subscriber_test.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require "abstract_unit" +require "active_support/testing/event_reporter_assertions" +require "rails/structured_event_subscriber" + +module Rails + class StructuredEventSubscriberTest < ActiveSupport::TestCase + include ActiveSupport::Testing::EventReporterAssertions + + def run(*) + ActiveSupport.event_reporter.with_debug do + super + end + end + + def test_deprecation_is_notified_when_behavior_is_notify + Rails.deprecator.with(behavior: :notify) do + event = assert_event_reported("rails.deprecation", payload: { gem_name: "Rails" }) do + Rails.deprecator.warn("This is a deprecation warning") + end + + assert_includes event[:payload][:message], "This is a deprecation warning" + assert_includes event[:payload].keys, :callstack + assert_includes event[:payload].keys, :gem_name + assert_includes event[:payload].keys, :deprecation_horizon + end + end + + def test_deprecation_is_not_notified_when_behavior_is_not_notify + Rails.deprecator.with(behavior: :stderr) do + output = capture(:stderr) do + assert_no_event_reported("rails.deprecation") do + Rails.deprecator.warn("This is a deprecation warning") + end + end + + assert_includes output, "This is a deprecation warning" + end + end + end +end From d8001119e52c838b5070f3b41d54a36ef0d30901 Mon Sep 17 00:00:00 2001 From: Jonas Pardeyke <142009152+pardeyke@users.noreply.github.com> Date: Thu, 16 Oct 2025 01:13:38 +0200 Subject: [PATCH 0788/1075] Adding dark mode to http error pages (#55671) * adding dark mode to http error pages * added a brighter hue of red to the error name in dark mode for better readability * decrease error id brigtness for better readability * better contrast (aa compliant) --- .../rails/app/templates/public/400.html | 27 +++++++++++++++++-- .../rails/app/templates/public/404.html | 27 +++++++++++++++++-- .../public/406-unsupported-browser.html | 27 +++++++++++++++++-- .../rails/app/templates/public/422.html | 27 +++++++++++++++++-- .../rails/app/templates/public/500.html | 27 +++++++++++++++++-- 5 files changed, 125 insertions(+), 10 deletions(-) diff --git a/railties/lib/rails/generators/rails/app/templates/public/400.html b/railties/lib/rails/generators/rails/app/templates/public/400.html index f59c79ab82f05..8973391c5461c 100644 --- a/railties/lib/rails/generators/rails/app/templates/public/400.html +++ b/railties/lib/rails/generators/rails/app/templates/public/400.html @@ -35,12 +35,35 @@ font-weight: 400; letter-spacing: -0.0025em; line-height: 1.4; - min-height: 100vh; + min-height: 100dvh; place-items: center; text-rendering: optimizeLegibility; -webkit-text-size-adjust: 100%; } + #error-description { + fill: #d30001; + } + + #error-id { + fill: #f0eff0; + } + + @media (prefers-color-scheme: dark) { + body { + background: #101010; + color: #e0e0e0; + } + + #error-description { + fill: #FF6161; + } + + #error-id { + fill: #2c2c2c; + } + } + a { color: inherit; font-weight: 700; @@ -102,7 +125,7 @@
- +

The server cannot process the request due to a client error. Please check the request and try again. If you're the application owner check the logs for more information.

diff --git a/railties/lib/rails/generators/rails/app/templates/public/404.html b/railties/lib/rails/generators/rails/app/templates/public/404.html index 26d16027c6a4c..37c1c1cefe05a 100644 --- a/railties/lib/rails/generators/rails/app/templates/public/404.html +++ b/railties/lib/rails/generators/rails/app/templates/public/404.html @@ -35,12 +35,35 @@ font-weight: 400; letter-spacing: -0.0025em; line-height: 1.4; - min-height: 100vh; + min-height: 100dvh; place-items: center; text-rendering: optimizeLegibility; -webkit-text-size-adjust: 100%; } + #error-description { + fill: #d30001; + } + + #error-id { + fill: #f0eff0; + } + + @media (prefers-color-scheme: dark) { + body { + background: #101010; + color: #e0e0e0; + } + + #error-description { + fill: #FF6161; + } + + #error-id { + fill: #2c2c2c; + } + } + a { color: inherit; font-weight: 700; @@ -102,7 +125,7 @@
- +

The page you were looking for doesn't exist. You may have mistyped the address or the page may have moved. If you're the application owner check the logs for more information.

diff --git a/railties/lib/rails/generators/rails/app/templates/public/406-unsupported-browser.html b/railties/lib/rails/generators/rails/app/templates/public/406-unsupported-browser.html index 9532a9ccd0f5c..9d9c04e8ec975 100644 --- a/railties/lib/rails/generators/rails/app/templates/public/406-unsupported-browser.html +++ b/railties/lib/rails/generators/rails/app/templates/public/406-unsupported-browser.html @@ -35,12 +35,35 @@ font-weight: 400; letter-spacing: -0.0025em; line-height: 1.4; - min-height: 100vh; + min-height: 100dvh; place-items: center; text-rendering: optimizeLegibility; -webkit-text-size-adjust: 100%; } + #error-description { + fill: #d30001; + } + + #error-id { + fill: #f0eff0; + } + + @media (prefers-color-scheme: dark) { + body { + background: #101010; + color: #e0e0e0; + } + + #error-description { + fill: #FF6161; + } + + #error-id { + fill: #2c2c2c; + } + } + a { color: inherit; font-weight: 700; @@ -102,7 +125,7 @@
- +

Your browser is not supported.
Please upgrade your browser to continue.

diff --git a/railties/lib/rails/generators/rails/app/templates/public/422.html b/railties/lib/rails/generators/rails/app/templates/public/422.html index ed5a5805d0e5f..cf6215074119f 100644 --- a/railties/lib/rails/generators/rails/app/templates/public/422.html +++ b/railties/lib/rails/generators/rails/app/templates/public/422.html @@ -35,12 +35,35 @@ font-weight: 400; letter-spacing: -0.0025em; line-height: 1.4; - min-height: 100vh; + min-height: 100dvh; place-items: center; text-rendering: optimizeLegibility; -webkit-text-size-adjust: 100%; } + #error-description { + fill: #d30001; + } + + #error-id { + fill: #f0eff0; + } + + @media (prefers-color-scheme: dark) { + body { + background: #101010; + color: #e0e0e0; + } + + #error-description { + fill: #FF6161; + } + + #error-id { + fill: #2c2c2c; + } + } + a { color: inherit; font-weight: 700; @@ -102,7 +125,7 @@
- +

The change you wanted was rejected. Maybe you tried to change something you didn't have access to. If you're the application owner check the logs for more information.

diff --git a/railties/lib/rails/generators/rails/app/templates/public/500.html b/railties/lib/rails/generators/rails/app/templates/public/500.html index 318723853a010..eb8022521f1c4 100644 --- a/railties/lib/rails/generators/rails/app/templates/public/500.html +++ b/railties/lib/rails/generators/rails/app/templates/public/500.html @@ -35,11 +35,34 @@ font-weight: 400; letter-spacing: -0.0025em; line-height: 1.4; - min-height: 100vh; + min-height: 100dvh; place-items: center; text-rendering: optimizeLegibility; -webkit-text-size-adjust: 100%; } + + #error-description { + fill: #d30001; + } + + #error-id { + fill: #f0eff0; + } + + @media (prefers-color-scheme: dark) { + body { + background: #101010; + color: #e0e0e0; + } + + #error-description { + fill: #FF6161; + } + + #error-id { + fill: #2c2c2c; + } + } a { color: inherit; @@ -102,7 +125,7 @@
- +

We're sorry, but something went wrong.
If you're the application owner check the logs for more information.

From 3bb24fd7723dba6cf96a047a89dbb156466af1f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Wed, 15 Oct 2025 23:14:33 +0000 Subject: [PATCH 0789/1075] Fix error page indentation --- .../generators/rails/app/templates/public/400.html | 6 ++---- .../generators/rails/app/templates/public/404.html | 6 ++---- .../app/templates/public/406-unsupported-browser.html | 8 +++----- .../generators/rails/app/templates/public/422.html | 6 ++---- .../generators/rails/app/templates/public/500.html | 10 ++++------ 5 files changed, 13 insertions(+), 23 deletions(-) diff --git a/railties/lib/rails/generators/rails/app/templates/public/400.html b/railties/lib/rails/generators/rails/app/templates/public/400.html index 8973391c5461c..640de033979db 100644 --- a/railties/lib/rails/generators/rails/app/templates/public/400.html +++ b/railties/lib/rails/generators/rails/app/templates/public/400.html @@ -42,11 +42,11 @@ } #error-description { - fill: #d30001; + fill: #d30001; } #error-id { - fill: #f0eff0; + fill: #f0eff0; } @media (prefers-color-scheme: dark) { @@ -106,13 +106,11 @@ } main article br { - display: none; @media(min-width: 48em) { display: inline; } - } diff --git a/railties/lib/rails/generators/rails/app/templates/public/404.html b/railties/lib/rails/generators/rails/app/templates/public/404.html index 37c1c1cefe05a..d7f0f14222d10 100644 --- a/railties/lib/rails/generators/rails/app/templates/public/404.html +++ b/railties/lib/rails/generators/rails/app/templates/public/404.html @@ -42,11 +42,11 @@ } #error-description { - fill: #d30001; + fill: #d30001; } #error-id { - fill: #f0eff0; + fill: #f0eff0; } @media (prefers-color-scheme: dark) { @@ -106,13 +106,11 @@ } main article br { - display: none; @media(min-width: 48em) { display: inline; } - } diff --git a/railties/lib/rails/generators/rails/app/templates/public/406-unsupported-browser.html b/railties/lib/rails/generators/rails/app/templates/public/406-unsupported-browser.html index 9d9c04e8ec975..43d2811e8c5ae 100644 --- a/railties/lib/rails/generators/rails/app/templates/public/406-unsupported-browser.html +++ b/railties/lib/rails/generators/rails/app/templates/public/406-unsupported-browser.html @@ -42,11 +42,11 @@ } #error-description { - fill: #d30001; + fill: #d30001; } - + #error-id { - fill: #f0eff0; + fill: #f0eff0; } @media (prefers-color-scheme: dark) { @@ -106,13 +106,11 @@ } main article br { - display: none; @media(min-width: 48em) { display: inline; } - } diff --git a/railties/lib/rails/generators/rails/app/templates/public/422.html b/railties/lib/rails/generators/rails/app/templates/public/422.html index cf6215074119f..f12fb4aa1752e 100644 --- a/railties/lib/rails/generators/rails/app/templates/public/422.html +++ b/railties/lib/rails/generators/rails/app/templates/public/422.html @@ -42,11 +42,11 @@ } #error-description { - fill: #d30001; + fill: #d30001; } #error-id { - fill: #f0eff0; + fill: #f0eff0; } @media (prefers-color-scheme: dark) { @@ -106,13 +106,11 @@ } main article br { - display: none; @media(min-width: 48em) { display: inline; } - } diff --git a/railties/lib/rails/generators/rails/app/templates/public/500.html b/railties/lib/rails/generators/rails/app/templates/public/500.html index eb8022521f1c4..e4eb18a759909 100644 --- a/railties/lib/rails/generators/rails/app/templates/public/500.html +++ b/railties/lib/rails/generators/rails/app/templates/public/500.html @@ -40,13 +40,13 @@ text-rendering: optimizeLegibility; -webkit-text-size-adjust: 100%; } - + #error-description { - fill: #d30001; + fill: #d30001; } #error-id { - fill: #f0eff0; + fill: #f0eff0; } @media (prefers-color-scheme: dark) { @@ -58,7 +58,7 @@ #error-description { fill: #FF6161; } - + #error-id { fill: #2c2c2c; } @@ -106,13 +106,11 @@ } main article br { - display: none; @media(min-width: 48em) { display: inline; } - } From a2c51def1e50f68ab2605c21406f6f3f42e8d174 Mon Sep 17 00:00:00 2001 From: Ben Garcia Date: Thu, 2 Oct 2025 15:30:24 -0700 Subject: [PATCH 0790/1075] Add prefix option to has_secure_token for improved token identification Adds an optional :prefix paramer to has_secure_token that prepends a string to generated tokens, making token types immediately identifiable in logs, debugging sessions, and error messages. Before: user.auth_token # => "pX27zsMN2ViQKta1bGfLmVJE" user.reset_token # => "tU9bLuZseefXQ4yQxQo8wjtB" After: has_secure_token :auth_token, prefix: "auth_" has_secure_token :reset_token, prefix: "reset_" user.auth_token # => "auth_pX27zsMN2ViQKta1bGfLmVJE" user.reset_token # => "reset_tU9bLuZseefXQ4yQxQo8wjtB" This enables better developer clarity without additional database columns or complex token parsing logic. Particularly valuable when analyzing application logs, supporting operations teams, and debugging authentication flows with multiple token types. Anecdotally, I have dozens of classes that I override the _feels_ far nicer to copy/paste a token with context to others than an string without any purpose encoded. Notably, the current implementation makes this a breaking change for anyone who has overriden the #generates_unique_secure_token like myself. Add test case for test_token_with_prefix Add missing quotation Update documentation notes Add prefix option to has_secure_token for improved token identification Adds an optional :prefix paramer to has_secure_token that prepends a string to generated tokens, making token types immediately identifiable in logs, debugging sessions, and error messages. Before: user.auth_token # => "pX27zsMN2ViQKta1bGfLmVJE" user.reset_token # => "tU9bLuZseefXQ4yQxQo8wjtB" After: has_secure_token :auth_token, prefix: "auth_" has_secure_token :reset_token, prefix: "reset_" user.auth_token # => "auth_pX27zsMN2ViQKta1bGfLmVJE" user.reset_token # => "reset_tU9bLuZseefXQ4yQxQo8wjtB" This enables better developer clarity without additional database columns or complex token parsing logic. Particularly valuable when analyzing application logs, supporting operations teams, and debugging authentication flows with multiple token types. Anecdotally, I have dozens of classes that I override the _feels_ far nicer to copy/paste a token with context to others than an string without any purpose encoded. Notably, the current implementation makes this a breaking change for anyone who has overriden the #generates_unique_secure_token like myself. Add test case for test_token_with_prefix Add missing quotation Update documentation notes Add TrueClass option for secure_token prefix --- .../lib/active_record/secure_token.rb | 33 +++++++++++++++---- activerecord/test/cases/secure_token_test.rb | 14 ++++++++ 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/activerecord/lib/active_record/secure_token.rb b/activerecord/lib/active_record/secure_token.rb index 3ec836fa82688..5b4c15b9aa761 100644 --- a/activerecord/lib/active_record/secure_token.rb +++ b/activerecord/lib/active_record/secure_token.rb @@ -14,14 +14,17 @@ module ClassMethods # # Schema: User(token:string, auth_token:string) # class User < ActiveRecord::Base # has_secure_token - # has_secure_token :auth_token, length: 36 + # has_secure_token :invite_token, prefix: true + # has_secure_token :auth_token, length: 36, prefix: "auth_" # end # # user = User.new # user.save # user.token # => "pX27zsMN2ViQKta1bGfLmVJE" - # user.auth_token # => "tU9bLuZseefXQ4yQxQo8wjtBvsAfPc78os6R" + # user.invite_token # => "invite_token_srUP2WWCb6yCtfy6CAKVdzxF" + # user.auth_token # => "auth_tU9bLuZseefXQ4yQxQo8wjtBvsAfPc78os6R" # user.regenerate_token # => true + # user.regenerate_invite_token # => true # user.regenerate_auth_token # => true # # +SecureRandom::base58+ is used to generate at minimum a 24-character unique token, so collisions are highly unlikely. @@ -33,8 +36,7 @@ module ClassMethods # ==== Options # # [+:length+] - # Length of the Secure Random, with a minimum of 24 characters. It will - # default to 24. + # Length of the randomly generated token, with a minimum of 24 characters. Defaults to 24. # # [+:on+] # The callback when the value is generated. When called with on: @@ -43,17 +45,34 @@ module ClassMethods # 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) + # + # [+:prefix+] + # An optional string prepended to the generated token. Intended primarily for keys that + # will be publicly visible (e.g. API keys, URLs, etc.) so that their purpose can be identified at a glance. + # The prefix does not count toward +:length+. + def has_secure_token(attribute = :token, length: MINIMUM_TOKEN_LENGTH, on: ActiveRecord.generate_secure_token_on, prefix: nil) if length < MINIMUM_TOKEN_LENGTH raise MinimumLengthError, "Token requires a minimum length of #{MINIMUM_TOKEN_LENGTH} characters." end + prefix = "#{attribute}_" if prefix == true + + if prefix + generate_token = -> do + token = self.generate_unique_secure_token(length: length) + + "#{prefix}#{token}" + end + else + generate_token = -> { self.generate_unique_secure_token(length: length) } + end + # Load securerandom only when has_secure_token is used. require "active_support/core_ext/securerandom" - define_method("regenerate_#{attribute}") { update! attribute => self.class.generate_unique_secure_token(length: length) } + define_method("regenerate_#{attribute}") { update! attribute => generate_token.call } set_callback on, on == :initialize ? :after : :before do if new_record? && !query_attribute(attribute) - send("#{attribute}=", self.class.generate_unique_secure_token(length: length)) + send("#{attribute}=", generate_token.call) end end end diff --git a/activerecord/test/cases/secure_token_test.rb b/activerecord/test/cases/secure_token_test.rb index 6873a043fcb8f..dc6723f02163e 100644 --- a/activerecord/test/cases/secure_token_test.rb +++ b/activerecord/test/cases/secure_token_test.rb @@ -116,4 +116,18 @@ def token=(value) assert_equal "#{user.token}_modified", user.modified_token end + + def test_token_with_prefix + model = Class.new(ActiveRecord::Base) do + self.table_name = "users" + attribute :auth_token + has_secure_token prefix: true, on: :initialize + has_secure_token :auth_token, on: :initialize, prefix: "auth_" + end + + user = model.new + + assert_match(/^token_/, user.token) + assert_match(/^auth/, user.auth_token) + end end From ffd25a56c5e9b05ff9e572cdc98f141d00b795bd Mon Sep 17 00:00:00 2001 From: Ruy Rocha <108208+ruyrocha@users.noreply.github.com> Date: Tue, 14 Oct 2025 22:50:09 -0300 Subject: [PATCH 0791/1075] [#55866] Fix SQLite3 data loss during table alterations with CASCADE foreign keys. --- activerecord/CHANGELOG.md | 22 +++ .../connection_adapters/sqlite3_adapter.rb | 4 +- .../adapters/sqlite3/sqlite3_adapter_test.rb | 126 ++++++++++++++++++ 3 files changed, 150 insertions(+), 2 deletions(-) diff --git a/activerecord/CHANGELOG.md b/activerecord/CHANGELOG.md index fe1e499fc7c9f..5bfb747860f0e 100644 --- a/activerecord/CHANGELOG.md +++ b/activerecord/CHANGELOG.md @@ -1,3 +1,25 @@ +* Fix SQLite3 data loss during table alterations with CASCADE foreign keys. + + When altering a table in SQLite3 that is referenced by child tables with + `ON DELETE CASCADE` foreign keys, ActiveRecord would silently delete all + data from the child tables. This occurred because SQLite requires table + recreation for schema changes, and during this process the original table + is temporarily dropped, triggering CASCADE deletes on child tables. + + The root cause was incorrect ordering of operations. The original code + wrapped `disable_referential_integrity` inside a transaction, but + `PRAGMA foreign_keys` cannot be modified inside a transaction in SQLite - + attempting to do so simply has no effect. This meant foreign keys remained + enabled during table recreation, causing CASCADE deletes to fire. + + The fix reverses the order to follow the official SQLite 12-step ALTER TABLE + procedure: `disable_referential_integrity` now wraps the transaction instead + of being wrapped by it. This ensures foreign keys are properly disabled + before the transaction starts and re-enabled after it commits, preventing + CASCADE deletes while maintaining data integrity through atomic transactions. + + *Ruy Rocha* + * Fix negative scopes for enums to include records with `nil` values. *fatkodima* diff --git a/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb b/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb index 7f8ffcd0bcd15..796b85b8f9e9e 100644 --- a/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb +++ b/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb @@ -639,8 +639,8 @@ def alter_table( yield definition if block_given? end - transaction do - disable_referential_integrity do + disable_referential_integrity do + transaction do move_table(table_name, altered_table_name, options.merge(temporary: true)) move_table(altered_table_name, table_name, &caller) end diff --git a/activerecord/test/cases/adapters/sqlite3/sqlite3_adapter_test.rb b/activerecord/test/cases/adapters/sqlite3/sqlite3_adapter_test.rb index 8e6e5468b45b4..0ce23b26fc3d9 100644 --- a/activerecord/test/cases/adapters/sqlite3/sqlite3_adapter_test.rb +++ b/activerecord/test/cases/adapters/sqlite3/sqlite3_adapter_test.rb @@ -1185,6 +1185,132 @@ def new_client(config) end end + def test_alter_table_with_fk_preserves_rows_when_referenced_table_altered + conn = SQLite3Adapter.new(database: ":memory:", adapter: "sqlite3", strict: false) + + conn.create_table :authors do |t| + t.string :name, null: false + end + + conn.create_table :books do |t| + t.string :title, null: false + t.integer :author_id, null: false + end + conn.add_foreign_key :books, :authors, on_delete: :cascade + + conn.execute("INSERT INTO authors (id, name) VALUES (1, 'Douglas Adams');") + conn.execute("INSERT INTO books (id, title, author_id) VALUES (42, 'The Hitchhiker''s Guide', 1);") + conn.execute("INSERT INTO books (id, title, author_id) VALUES (43, 'Restaurant at the End', 1);") + + initial_book_count = conn.select_value("SELECT COUNT(*) FROM books") + assert_equal 2, initial_book_count + + conn.add_column :authors, :email, :string + + book_count = conn.select_value("SELECT COUNT(*) FROM books") + author_count = conn.select_value("SELECT COUNT(*) FROM authors") + + assert_equal 2, book_count, "Books were CASCADE deleted when authors table was altered!" + assert_equal 1, author_count, "Authors were lost during table alteration!" + ensure + conn.disconnect! if conn + end + + def test_alter_table_with_fk_preserves_rows_when_adding_fk_to_referenced_table + conn = SQLite3Adapter.new(database: ":memory:", adapter: "sqlite3", strict: false) + + conn.create_table :groups do |t| + t.string :name, null: false + end + + conn.create_table :users do |t| + t.string :username, null: false + end + + conn.create_table :reports do |t| + t.string :title, null: false + t.integer :group_id, null: false + end + conn.add_foreign_key :reports, :groups, on_delete: :cascade + + conn.execute("INSERT INTO groups (id, name) VALUES (1, 'Admin Group');") + conn.execute("INSERT INTO users (id, username) VALUES (1, 'alice');") + conn.execute("INSERT INTO reports (id, title, group_id) VALUES (1, 'Report A', 1);") + conn.execute("INSERT INTO reports (id, title, group_id) VALUES (2, 'Report B', 1);") + + initial_report_count = conn.select_value("SELECT COUNT(*) FROM reports") + assert_equal 2, initial_report_count + + conn.add_column :groups, :owner_id, :integer + conn.add_foreign_key :groups, :users, column: :owner_id + + report_count = conn.select_value("SELECT COUNT(*) FROM reports") + group_count = conn.select_value("SELECT COUNT(*) FROM groups") + + assert_equal 2, report_count, "Reports were CASCADE deleted when groups table was altered!" + assert_equal 1, group_count, "Groups were lost during table alteration!" + ensure + conn.disconnect! if conn + end + + def test_alter_table_with_multiple_cascade_fks_preserves_all_data + conn = SQLite3Adapter.new(database: ":memory:", adapter: "sqlite3", strict: false) + + conn.create_table :authors do |t| + t.string :name, null: false + end + + conn.create_table :books do |t| + t.string :title, null: false + t.integer :author_id, null: false + end + conn.add_foreign_key :books, :authors, on_delete: :cascade + + conn.create_table :articles do |t| + t.string :headline, null: false + t.integer :author_id, null: false + end + conn.add_foreign_key :articles, :authors, on_delete: :cascade + + conn.execute("INSERT INTO authors (id, name) VALUES (1, 'Douglas Adams');") + conn.execute("INSERT INTO books (id, title, author_id) VALUES (1, 'HHGTTG', 1);") + conn.execute("INSERT INTO articles (id, headline, author_id) VALUES (1, 'Towel Day', 1);") + + conn.add_column :authors, :bio, :text + + book_count = conn.select_value("SELECT COUNT(*) FROM books") + article_count = conn.select_value("SELECT COUNT(*) FROM articles") + + assert_equal 1, book_count, "Books were CASCADE deleted when authors table was altered!" + assert_equal 1, article_count, "Articles were CASCADE deleted when authors table was altered!" + ensure + conn.disconnect! if conn + end + + def test_rename_table_with_cascade_fk_preserves_referencing_data + conn = SQLite3Adapter.new(database: ":memory:", adapter: "sqlite3", strict: false) + + conn.create_table :authors do |t| + t.string :name, null: false + end + + conn.create_table :books do |t| + t.string :title, null: false + t.integer :author_id, null: false + end + conn.add_foreign_key :books, :authors, on_delete: :cascade + + conn.execute("INSERT INTO authors (id, name) VALUES (1, 'Douglas Adams');") + conn.execute("INSERT INTO books (id, title, author_id) VALUES (1, 'HHGTTG', 1);") + + conn.rename_table :authors, :writers + + book_count = conn.select_value("SELECT COUNT(*) FROM books") + assert_equal 1, book_count, "Books were CASCADE deleted when authors table was renamed!" + ensure + conn.disconnect! if conn + end + private def with_rails_root(&block) mod = Module.new do From 96c61126baf54f48f669beab1530aec1621bf428 Mon Sep 17 00:00:00 2001 From: Aron Wolf Date: Sun, 21 Sep 2025 00:45:14 +0200 Subject: [PATCH 0792/1075] hidde backtrace when parallel tests get interrupted Right now if one presses CONTROL+C during the parallel test run, one get a very long backtrace that is very annoying. The same problem was solved 11 years ago for the non parallel tests here: * https://github.com/minitest/minitest/commit/b6ec36d086ac28e47e4e2dc7ffd995f462543f94 * https://github.com/minitest/minitest/issues/503 The reason why the 11 years old fix does not work for the parallel test is, that the rescue happens before the shutdown method gets executed. The shutdown method is where things gets executed. In my opinion, this is wrong, but my guess is, that it was done so, because doing it otherwise would probably require changes in the Minitest gem itself. NOTE: this is my first PR on the rails gem, so I apologize if I made something wrong. Also, there is a big probability that there is a better solution or that I miss some edge case that I am missing because of lack of knowledge. --- .../testing/parallelization/server.rb | 18 ++++++++++++++---- .../testing/parallelization/worker.rb | 5 ++--- railties/test/application/test_runner_test.rb | 2 +- 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/activesupport/lib/active_support/testing/parallelization/server.rb b/activesupport/lib/active_support/testing/parallelization/server.rb index 1f4af7d978b2b..e060327d8aad4 100644 --- a/activesupport/lib/active_support/testing/parallelization/server.rb +++ b/activesupport/lib/active_support/testing/parallelization/server.rb @@ -77,10 +77,7 @@ def shutdown @queue.close - # Wait until all workers have finished - while active_workers? - sleep 0.1 - end + wait_for_active_workers @in_flight.values.each do |(klass, name, reporter)| result = Minitest::Result.from(klass.new(name)) @@ -91,7 +88,20 @@ def shutdown reporter.record(result) end end + rescue Interrupt + warn "Interrupted. Exiting..." + + @queue.close + + wait_for_active_workers end + + private + def wait_for_active_workers + while active_workers? + sleep 0.1 + end + end end end end diff --git a/activesupport/lib/active_support/testing/parallelization/worker.rb b/activesupport/lib/active_support/testing/parallelization/worker.rb index b7e03e25dd393..d008277f8924c 100644 --- a/activesupport/lib/active_support/testing/parallelization/worker.rb +++ b/activesupport/lib/active_support/testing/parallelization/worker.rb @@ -25,6 +25,8 @@ def start rescue => @setup_exception; end work_from_queue + rescue Interrupt + @queue.interrupt ensure set_process_title("(stopping)") @@ -69,9 +71,6 @@ def safe_record(reporter, result) Minitest::UnexpectedError.new(error) end @queue.record(reporter, result) - rescue Interrupt - @queue.interrupt - raise end set_process_title("(idle)") diff --git a/railties/test/application/test_runner_test.rb b/railties/test/application/test_runner_test.rb index 5929bf05ebdba..fa7250b7e57e1 100644 --- a/railties/test/application/test_runner_test.rb +++ b/railties/test/application/test_runner_test.rb @@ -861,7 +861,7 @@ def test_verify_fail_fast matches = @test_output.match(/(\d+) runs, (\d+) assertions, (\d+) failures/) - assert_match %r{Interrupt}, @error_output + assert_empty @error_output assert_equal 1, matches[3].to_i assert_operator matches[1].to_i, :<, 11 end From d77afd2321f9b3604d52b7ad1c10750af3306329 Mon Sep 17 00:00:00 2001 From: Jan Grodowski Date: Thu, 24 Jul 2025 15:59:17 +0200 Subject: [PATCH 0793/1075] [Fix #55708] Respect the file_watcher config in the routes reloader --- railties/CHANGELOG.md | 4 ++++ railties/lib/rails/application.rb | 2 +- railties/lib/rails/application/routes_reloader.rb | 5 +++-- railties/test/application/loading_test.rb | 5 +---- railties/test/application/routing_test.rb | 10 ++++++++++ 5 files changed, 19 insertions(+), 7 deletions(-) diff --git a/railties/CHANGELOG.md b/railties/CHANGELOG.md index 2c34fde89cc1c..80179304f619b 100644 --- a/railties/CHANGELOG.md +++ b/railties/CHANGELOG.md @@ -1,3 +1,7 @@ +* `Rails::Application::RoutesReloader` uses the configured `Rails.application.config.file_watcher` + + *Jan Grodowski* + * Add structured event for Rails deprecations, when `config.active_support.deprecation` is set to `:notify`. *zzak* diff --git a/railties/lib/rails/application.rb b/railties/lib/rails/application.rb index e66ffe7a3b132..44583095844b8 100644 --- a/railties/lib/rails/application.rb +++ b/railties/lib/rails/application.rb @@ -419,7 +419,7 @@ def require_environment! # :nodoc: end def routes_reloader # :nodoc: - @routes_reloader ||= RoutesReloader.new + @routes_reloader ||= RoutesReloader.new(file_watcher: config.file_watcher) end # Returns an array of file paths appended with a hash of diff --git a/railties/lib/rails/application/routes_reloader.rb b/railties/lib/rails/application/routes_reloader.rb index 84cc1d548bef7..d9ddc839e1050 100644 --- a/railties/lib/rails/application/routes_reloader.rb +++ b/railties/lib/rails/application/routes_reloader.rb @@ -11,12 +11,13 @@ class RoutesReloader attr_writer :run_after_load_paths, :loaded # :nodoc: delegate :execute_if_updated, :updated?, to: :updater - def initialize + def initialize(file_watcher: ActiveSupport::FileUpdateChecker) @paths = [] @route_sets = [] @external_routes = [] @eager_load = false @loaded = false + @file_watcher = file_watcher end def reload! @@ -48,7 +49,7 @@ def updater hash[dir.to_s] = %w(rb) end - ActiveSupport::FileUpdateChecker.new(paths, dirs) { reload! } + @file_watcher.new(paths, dirs) { reload! } end end diff --git a/railties/test/application/loading_test.rb b/railties/test/application/loading_test.rb index ee75b99637627..079861cac60a9 100644 --- a/railties/test/application/loading_test.rb +++ b/railties/test/application/loading_test.rb @@ -191,11 +191,8 @@ def self.counter; 2; end test "does not reload constants on development if custom file watcher always returns false" do add_to_config <<-RUBY config.enable_reloading = true - config.file_watcher = Class.new do - def initialize(*); end + config.file_watcher = Class.new(ActiveSupport::FileUpdateChecker) do def updated?; false; end - def execute; end - def execute_if_updated; false; end end RUBY diff --git a/railties/test/application/routing_test.rb b/railties/test/application/routing_test.rb index 04ea22a6655b0..688c22c70c042 100644 --- a/railties/test/application/routing_test.rb +++ b/railties/test/application/routing_test.rb @@ -815,5 +815,15 @@ def index get "/" assert_equal 200, last_response.status end + + test "routes reloader uses configured file_watcher" do + add_to_config <<-RUBY + config.file_watcher = ActiveSupport::EventedFileUpdateChecker + RUBY + + app "development" + + assert_instance_of ActiveSupport::EventedFileUpdateChecker, Rails.application.routes_reloader.send(:updater) + end end end From 94d233308625c213a53a4ab4de186bce80fdefe6 Mon Sep 17 00:00:00 2001 From: kwkr Date: Wed, 8 Oct 2025 11:01:14 +0200 Subject: [PATCH 0794/1075] Refactor SQLiteDatabaseTasks structure_load - Pass command arguments as an array of string to avoid any escaping issues. - Report errors if the loading failed. Co-Authored-By: Jean Boussier --- .../lib/active_record/tasks/sqlite_database_tasks.rb | 6 ++++-- railties/test/application/rake/multi_dbs_test.rb | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/activerecord/lib/active_record/tasks/sqlite_database_tasks.rb b/activerecord/lib/active_record/tasks/sqlite_database_tasks.rb index d5b4dfc0d9843..b96ea8e1a6bf4 100644 --- a/activerecord/lib/active_record/tasks/sqlite_database_tasks.rb +++ b/activerecord/lib/active_record/tasks/sqlite_database_tasks.rb @@ -51,8 +51,10 @@ def structure_dump(filename, extra_flags) end def structure_load(filename, extra_flags) - flags = extra_flags.join(" ") if extra_flags - `sqlite3 #{flags} #{db_config.database} < "#{filename}"` + args = [] + args.concat(extra_flags) if extra_flags + args << db_config.database + run_cmd("sqlite3", *args, in: filename) end def check_current_protected_environment!(db_config, migration_class) diff --git a/railties/test/application/rake/multi_dbs_test.rb b/railties/test/application/rake/multi_dbs_test.rb index e2e38da517834..45d01cf64d200 100644 --- a/railties/test/application/rake/multi_dbs_test.rb +++ b/railties/test/application/rake/multi_dbs_test.rb @@ -106,7 +106,7 @@ def db_migrate_and_schema_dump_and_load(schema_format = "ruby") assert_match(/CREATE TABLE (?:IF NOT EXISTS )?"dogs"/, schema_dump_animals) end - rails "db:schema:load" + rails "db:drop", "db:create", "db:schema:load" ar_tables = lambda { rails("runner", "p ActiveRecord::Base.lease_connection.tables.sort").strip } animals_tables = lambda { rails("runner", "p AnimalsBase.lease_connection.tables.sort").strip } @@ -146,7 +146,7 @@ def db_migrate_and_schema_dump_and_load_one_database(database, schema_format) end end - rails "db:schema:load:#{database}" + rails "db:drop:#{database}", "db:create:#{database}", "db:schema:load:#{database}" ar_tables = lambda { rails("runner", "p ActiveRecord::Base.lease_connection.tables.sort").strip } animals_tables = lambda { rails("runner", "p AnimalsBase.lease_connection.tables.sort").strip } From eac1f55425de30c614cb1410c19e13921bfeb164 Mon Sep 17 00:00:00 2001 From: Sean Doyle Date: Mon, 18 Dec 2023 10:59:28 -0500 Subject: [PATCH 0795/1075] Treat `as: :html` tests request params as `:url_encoded_form` Closes [#50345][] First, handle the exception mentioned in [#50345][]: ``` BugTest#test_params_with_htm_content_type: NoMethodError: undefined method `to_html' for {:name=>"Muad'Dib"}:Hash .../actionpack/lib/action_dispatch/testing/request_encoder.rb:39:in `encode_params' .../actionpack/lib/action_dispatch/testing/integration.rb:251:in `process' ``` Calls with `as: :html` result in a `NoMethodError` because `Hash#to_html` does not exist. Passing `as: :html` implies that the request parameters will come from a `POST` body encoded as `text/html`. That isn't entirely true -- browsers will encode `POST` parameters as with the `Content-Type:` header set to either [application/x-www-form-urlencoded][] or [multipart/form-data][]. This commit skips setting the `CONTENT_TYPE` Rack header when processed with `as: :html`. To account for that, extend the `RequestEncoder` constructor to accept a `content_type `argument to use when provided. When omitted, continue to fall back to the provided MIME type. Extend the default `:html` encoder configuration to default to submitting with `Content-Type: x-www-form-urlencoded`. [#50345]: https://github.com/rails/rails/issues/50345 [application/x-www-form-urlencoded]: https://developer.mozilla.org/en-US/docs/Learn/Forms/Sending_and_retrieving_form_data#the_post_method [multipart/form-data]: https://developer.mozilla.org/en-US/docs/Learn/Forms/Sending_and_retrieving_form_data#the_enctype_attribute --- actionpack/CHANGELOG.md | 3 +++ .../action_dispatch/testing/request_encoder.rb | 18 +++++++++--------- actionpack/test/controller/integration_test.rb | 17 +++++++++++++++++ 3 files changed, 29 insertions(+), 9 deletions(-) diff --git a/actionpack/CHANGELOG.md b/actionpack/CHANGELOG.md index faadbdf692a9e..68254e0c9b422 100644 --- a/actionpack/CHANGELOG.md +++ b/actionpack/CHANGELOG.md @@ -1,2 +1,5 @@ +* Submit test requests using `as: :html` with `Content-Type: x-www-form-urlencoded` + + *Sean Doyle* Please check [8-1-stable](https://github.com/rails/rails/blob/8-1-stable/actionpack/CHANGELOG.md) for previous changes. diff --git a/actionpack/lib/action_dispatch/testing/request_encoder.rb b/actionpack/lib/action_dispatch/testing/request_encoder.rb index f1b6ad82e8885..e0ce6b30af113 100644 --- a/actionpack/lib/action_dispatch/testing/request_encoder.rb +++ b/actionpack/lib/action_dispatch/testing/request_encoder.rb @@ -3,6 +3,7 @@ # :markup: markdown require "nokogiri" +require "action_dispatch/http/mime_type" module ActionDispatch class RequestEncoder # :nodoc: @@ -15,9 +16,9 @@ def response_parser; -> body { body }; end @encoders = { identity: IdentityEncoder.new } - attr_reader :response_parser + attr_reader :response_parser, :content_type - def initialize(mime_name, param_encoder, response_parser) + def initialize(mime_name, param_encoder, response_parser, content_type) @mime = Mime[mime_name] unless @mime @@ -27,10 +28,7 @@ def initialize(mime_name, param_encoder, response_parser) @response_parser = response_parser || -> body { body } @param_encoder = param_encoder || :"to_#{@mime.symbol}".to_proc - end - - def content_type - @mime.to_s + @content_type = content_type || @mime.to_s end def accept_header @@ -50,11 +48,13 @@ def self.encoder(name) @encoders[name] || @encoders[:identity] end - def self.register_encoder(mime_name, param_encoder: nil, response_parser: nil) - @encoders[mime_name] = new(mime_name, param_encoder, response_parser) + def self.register_encoder(mime_name, param_encoder: nil, response_parser: nil, content_type: nil) + @encoders[mime_name] = new(mime_name, param_encoder, response_parser, content_type) end - register_encoder :html, response_parser: -> body { Rails::Dom::Testing.html_document.parse(body) } + register_encoder :html, response_parser: -> body { Rails::Dom::Testing.html_document.parse(body) }, + param_encoder: -> param { param }, + content_type: Mime[:url_encoded_form].to_s register_encoder :json, response_parser: -> body { JSON.parse(body, object_class: ActiveSupport::HashWithIndifferentAccess) } end end diff --git a/actionpack/test/controller/integration_test.rb b/actionpack/test/controller/integration_test.rb index 556c7d328d53b..9457b38eb8d7b 100644 --- a/actionpack/test/controller/integration_test.rb +++ b/actionpack/test/controller/integration_test.rb @@ -1098,6 +1098,12 @@ def foos render plain: "ok" end + def foos_html + render inline: <<~ERB + <%= params.permit(:foo) %> + ERB + end + def foos_json render json: params.permit(:foo) end @@ -1127,6 +1133,17 @@ def test_standard_json_encoding_works end end + def test_encoding_as_html + post_to_foos as: :html do + assert_response :success + assert_equal "application/x-www-form-urlencoded", request.media_type + assert_equal "text/html", request.accepts.first.to_s + assert_equal :html, request.format.ref + assert_equal({ "foo" => "fighters" }, request.request_parameters) + assert_equal({ "foo" => "fighters" }.to_s, response.parsed_body.at("code").text) + end + end + def test_encoding_as_json post_to_foos as: :json do assert_response :success From 7c30727a1bb464e7340d059e04559767fdd5fd40 Mon Sep 17 00:00:00 2001 From: Hartley McGuire Date: Thu, 16 Oct 2025 10:34:00 -0400 Subject: [PATCH 0796/1075] Fix passing both module: and shallow: to resources This broke because the keyword arg change started always passing `shallow:` through to this `scope` block. However, this caused an issue because passing the `shallow` option caused it to _always_ override the scope's current `shallow` value, instead of only doing that when specified. This commit fixes the behavior by only passing `shallow` to the `scope` when it is specified, instead of unconditionally. Co-authored-by: Marek Kasztelnik --- .../lib/action_dispatch/routing/mapper.rb | 6 ++++-- actionpack/test/controller/resources_test.rb | 21 +++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/actionpack/lib/action_dispatch/routing/mapper.rb b/actionpack/lib/action_dispatch/routing/mapper.rb index 9b0ed34e821b4..9b96de1b1a9b4 100644 --- a/actionpack/lib/action_dispatch/routing/mapper.rb +++ b/actionpack/lib/action_dispatch/routing/mapper.rb @@ -1948,8 +1948,10 @@ def apply_common_behavior_for(method, resources, shallow: nil, **options, &block end scope_options = options.slice!(*RESOURCE_OPTIONS) - if !scope_options.empty? || !shallow.nil? - scope(**scope_options, shallow:) do + scope_options[:shallow] = shallow unless shallow.nil? + + unless scope_options.empty? + scope(**scope_options) do public_send(method, resources.pop, **options, &block) end return true diff --git a/actionpack/test/controller/resources_test.rb b/actionpack/test/controller/resources_test.rb index a9806129dfb55..dc20b0deab21c 100644 --- a/actionpack/test/controller/resources_test.rb +++ b/actionpack/test/controller/resources_test.rb @@ -18,6 +18,10 @@ class ImagesController < ResourcesController; end end end +module Products + class ImagesController < ResourcesController; end +end + class ResourcesTest < ActionController::TestCase def test_default_restful_routes with_restful_routing :messages do @@ -511,6 +515,23 @@ def test_shallow_nested_restful_routes_with_namespaces end end + def test_shallow_with_module + with_routing do |set| + set.draw do + resources :products do + resources :images, module: :products, shallow: true + end + end + + assert_simply_restful_for :images, + controller: "products/images", + name_prefix: "product_", + path_prefix: "products/1/", + shallow: true, + options: { product_id: "1" } + end + end + def test_restful_routes_dont_generate_duplicates with_restful_routing :messages do routes = @routes.routes From 5ff38f4854fa889f13e1e7e90ef594d6cee47ba3 Mon Sep 17 00:00:00 2001 From: Harsh Date: Tue, 23 Sep 2025 14:25:12 -0400 Subject: [PATCH 0797/1075] [Getting Started Tutorial] Add note on default string type for email column for Subscriber and explain the resulting migration file When I was doing the tutorial, I noticed that the `email` column was not specified with a type yet it defaulted to a string. This confused me, so I wanted to add more detail about it. I added a note for the default with showing the generated migration file. I tried looking for documentation on this, but didn't find anything. When I tried it in a few different projects, this did work. I did find the relevant code in `railties/lib/rails/generators/generated_attribute.rb` where ```ruby def initialize(name, type = nil, index_type = false, attr_options = {}) @name = name @type = type || :string ``` In the tutorial I don't want to link to the actual codebase, but I think there's a few more pages that I can add a note too. * https://guides.rubyonrails.org/active_record_migrations.html * https://guides.rubyonrails.org/command_line.html * https://guides.rubyonrails.org/active_record_basics.html Not sure if there's any other API specifc pages I can add a note to. If I do add these, should this be in this PR or a separate one? Before submitting the PR make sure the following are checked: * [x] This Pull Request is related to one change. Unrelated changes should be opened in separate PRs. * [x] Commit message has a detailed description of what changed and why. If this PR fixes a related issue include it in the commit message. Ex: `[Fix #issue-number]` * [x] Tests are added or updated if you fix a bug or add a feature. - N/A * [x] CHANGELOG files are updated for the changed libraries if there is a behavior change or additional feature. Minor bug fixes and documentation changes should not be included. - N/A --- guides/source/getting_started.md | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/guides/source/getting_started.md b/guides/source/getting_started.md index 32d8515aeeae2..ed898765148be 100644 --- a/guides/source/getting_started.md +++ b/guides/source/getting_started.md @@ -2031,20 +2031,37 @@ of these subscribers. Let's generate a model called Subscriber to store these email addresses and associate them with the respective product. +NOTE: Here we are not specifying a type for `email` as rails automatically defaults to a `string` when a type is not given for migrations. + ```bash $ bin/rails generate model Subscriber product:belongs_to email ``` +By including `product:belongs_to` above, we told Rails that subscribers and products have a one-to-many relationship, meaning a Subscriber "belongs to" a single Product instance. + +Next, open the generated migration (`db/migrate/_create_subscribers.rb`) like we did for Product. + +```ruby#4-5 +class CreateSubscribers < ActiveRecord::Migration[8.1] + def change + create_table :subscribers do |t| + t.belongs_to :product, null: false, foreign_key: true + t.string :email + + t.timestamps + end + end +end +``` + +This looks quite similar to the migration for `Product`, the main new thing is `belongs_to` which adds a `product_id` foreign key column. + Then run the new migration: ```bash $ bin/rails db:migrate ``` -By including `product:belongs_to` above, we told Rails that subscribers and -products have a one-to-many relationship, meaning a Subscriber "belongs to" a -single Product instance. - A Product, however, can have many subscribers, so we then add `has_many :subscribers, dependent: :destroy` to our Product model to add the second part of this association between the two models. This tells Rails how to From 0d6c08de1ed41888ceec7281a66bb4061d1196a2 Mon Sep 17 00:00:00 2001 From: Nicolas Bachschmidt Date: Thu, 16 Oct 2025 17:53:33 +0200 Subject: [PATCH 0798/1075] Add support for bound SQL literals in CTEs When supplying bind value parameters, Arel.sql returns an instance of Arel::Nodes::BoundSqlLiteral. Fixes #55917 --- activerecord/lib/active_record/relation/query_methods.rb | 3 ++- activerecord/test/cases/relation/with_test.rb | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/activerecord/lib/active_record/relation/query_methods.rb b/activerecord/lib/active_record/relation/query_methods.rb index 7d34f39e842b1..20dc65f88f489 100644 --- a/activerecord/lib/active_record/relation/query_methods.rb +++ b/activerecord/lib/active_record/relation/query_methods.rb @@ -1923,7 +1923,8 @@ def build_with_value_from_hash(hash) def build_with_expression_from_value(value, nested = false) case value - when Arel::Nodes::SqlLiteral then Arel::Nodes::Grouping.new(value) + when Arel::Nodes::SqlLiteral, Arel::Nodes::BoundSqlLiteral + Arel::Nodes::Grouping.new(value) when ActiveRecord::Relation if nested value.arel.ast diff --git a/activerecord/test/cases/relation/with_test.rb b/activerecord/test/cases/relation/with_test.rb index 6aa1673d878e0..6b16dfd086af7 100644 --- a/activerecord/test/cases/relation/with_test.rb +++ b/activerecord/test/cases/relation/with_test.rb @@ -28,7 +28,8 @@ def test_with_when_hash_is_passed_as_an_argument def test_with_when_hash_with_multiple_elements_of_different_type_is_passed_as_an_argument cte_options = { posts_with_tags: Post.arel_table.project(Arel.star).where(Post.arel_table[:tags_count].gt(0)), - posts_with_tags_and_comments: Arel.sql("SELECT * FROM posts_with_tags WHERE legacy_comments_count > 0"), + posts_with_tags_and_true: Arel.sql("SELECT * FROM posts_with_tags WHERE true"), + posts_with_tags_and_comments: Arel.sql("SELECT * FROM posts_with_tags_and_true WHERE tags_count > ?", 0), "posts_with_tags_and_multiple_comments" => Post.where("legacy_comments_count > 1").from("posts_with_tags_and_comments AS posts") } relation = Post.with(cte_options).from("posts_with_tags_and_multiple_comments AS posts") From 89ea0311033f362d8ddf85629bf34e78eba501f4 Mon Sep 17 00:00:00 2001 From: Bart de Water <496367+bdewater@users.noreply.github.com> Date: Thu, 9 Nov 2023 12:48:52 -0500 Subject: [PATCH 0799/1075] Let Blob#open return a tempfile for manual unlinking Wrapping code in a block is not always possible, this way it behaves more like stdlib Tempfile. --- activestorage/CHANGELOG.md | 4 +++ .../app/models/active_storage/blob.rb | 5 ++-- activestorage/lib/active_storage/analyzer.rb | 2 +- .../lib/active_storage/downloader.rb | 25 ++++++++----------- activestorage/lib/active_storage/previewer.rb | 2 +- activestorage/test/models/blob_test.rb | 17 ++++++++++++- 6 files changed, 35 insertions(+), 20 deletions(-) diff --git a/activestorage/CHANGELOG.md b/activestorage/CHANGELOG.md index f520a6e902c95..e15170ebed030 100644 --- a/activestorage/CHANGELOG.md +++ b/activestorage/CHANGELOG.md @@ -1,2 +1,6 @@ +* `ActiveStorage::Blob#open` can now be used without passing a block, like `Tempfile.open`. When using this form the + returned temporary file must be unlinked manually. + + *Bart de Water* Please check [8-1-stable](https://github.com/rails/rails/blob/8-1-stable/activestorage/CHANGELOG.md) for previous changes. diff --git a/activestorage/app/models/active_storage/blob.rb b/activestorage/app/models/active_storage/blob.rb index 1e85ee3d36002..91fce0f2852e1 100644 --- a/activestorage/app/models/active_storage/blob.rb +++ b/activestorage/app/models/active_storage/blob.rb @@ -272,7 +272,8 @@ def download_chunk(range) service.download_chunk key, range end - # Downloads the blob to a tempfile on disk. Yields the tempfile. + # Downloads the blob to a temporary file on disk. If a block is given, the file is automatically closed and unlinked + # after the block executed. Otherwise the file is returned and you are responsible for closing and unlinking. # # The tempfile's name is prefixed with +ActiveStorage-+ and the blob's ID. Its extension matches that of the blob. # @@ -282,8 +283,6 @@ def download_chunk(range) # # ... # end # - # The tempfile is automatically closed and unlinked after the given block is executed. - # # Raises ActiveStorage::IntegrityError if the downloaded data does not match the blob's checksum. def open(tmpdir: nil, &block) service.open( diff --git a/activestorage/lib/active_storage/analyzer.rb b/activestorage/lib/active_storage/analyzer.rb index 4463a2e117606..75723ce9818f3 100644 --- a/activestorage/lib/active_storage/analyzer.rb +++ b/activestorage/lib/active_storage/analyzer.rb @@ -30,7 +30,7 @@ def metadata end private - # Downloads the blob to a tempfile on disk. Yields the tempfile. + # Downloads the blob to a tempfile on disk. See ActiveStorage::Blob#open for details. def download_blob_to_tempfile(&block) # :doc: blob.open tmpdir: tmpdir, &block end diff --git a/activestorage/lib/active_storage/downloader.rb b/activestorage/lib/active_storage/downloader.rb index 319c39f94410a..1f52f7755da25 100644 --- a/activestorage/lib/active_storage/downloader.rb +++ b/activestorage/lib/active_storage/downloader.rb @@ -8,27 +8,24 @@ def initialize(service) @service = service end - def open(key, checksum: nil, verify: true, name: "ActiveStorage-", tmpdir: nil) - open_tempfile(name, tmpdir) do |file| - download key, file - verify_integrity_of(file, checksum: checksum) if verify - yield file - end - end - - private - def open_tempfile(name, tmpdir = nil) - file = Tempfile.open(name, tmpdir) + def open(key, checksum: nil, verify: true, name: "ActiveStorage-", tmpdir: nil, &block) + tempfile = Tempfile.new(name, tmpdir, binmode: true) + download(key, tempfile) + verify_integrity_of(tempfile, checksum: checksum) if verify + if block_given? begin - yield file + yield tempfile ensure - file.close! + tempfile.close! end + else + tempfile end + end + private def download(key, file) - file.binmode service.download(key) { |chunk| file.write(chunk) } file.flush file.rewind diff --git a/activestorage/lib/active_storage/previewer.rb b/activestorage/lib/active_storage/previewer.rb index 05146de704b59..75c9ddf15ca0c 100644 --- a/activestorage/lib/active_storage/previewer.rb +++ b/activestorage/lib/active_storage/previewer.rb @@ -27,7 +27,7 @@ def preview(**options) end private - # Downloads the blob to a tempfile on disk. Yields the tempfile. + # Downloads the blob to a tempfile on disk. See ActiveStorage::Blob#open for details. def download_blob_to_tempfile(&block) # :doc: blob.open tmpdir: tmpdir, &block end diff --git a/activestorage/test/models/blob_test.rb b/activestorage/test/models/blob_test.rb index 29003a70fd958..0849dc985d97d 100644 --- a/activestorage/test/models/blob_test.rb +++ b/activestorage/test/models/blob_test.rb @@ -174,7 +174,7 @@ class ActiveStorage::BlobTest < ActiveSupport::TestCase assert_equal "a" * 64.kilobytes, chunks.second end - test "open with integrity" do + test "open yielding with integrity" do create_file_blob(filename: "racecar.jpg").tap do |blob| blob.open do |file| assert_predicate file, :binmode? @@ -186,6 +186,21 @@ class ActiveStorage::BlobTest < ActiveSupport::TestCase end end + test "open returning with integrity" do + file = nil + create_file_blob(filename: "racecar.jpg").tap do |blob| + file = blob.open + + assert_predicate file, :binmode? + assert_equal 0, file.pos + assert File.basename(file.path).start_with?("ActiveStorage-#{blob.id}-") + assert file.path.end_with?(".jpg") + assert_equal file_fixture("racecar.jpg").binread, file.read, "Expected downloaded file to match fixture file" + ensure + file&.close! + end + end + test "open without integrity" do create_blob(data: "Hello, world!").tap do |blob| blob.update! checksum: OpenSSL::Digest::MD5.base64digest("Goodbye, world!") From 6ff7803661ac7ef18416ba1c4bc72861c009b420 Mon Sep 17 00:00:00 2001 From: Eugene Date: Sun, 29 Sep 2024 18:56:48 +0700 Subject: [PATCH 0800/1075] Add error type support to messages_for and full_messages_for methods --- activemodel/CHANGELOG.md | 15 ++++++++++++++- activemodel/lib/active_model/errors.rb | 20 ++++++++++++++------ activemodel/test/cases/errors_test.rb | 20 ++++++++++++++++++++ 3 files changed, 48 insertions(+), 7 deletions(-) diff --git a/activemodel/CHANGELOG.md b/activemodel/CHANGELOG.md index 017c5a199e751..7185340547aed 100644 --- a/activemodel/CHANGELOG.md +++ b/activemodel/CHANGELOG.md @@ -1,4 +1,17 @@ -* Make `ActiveModel::Serializers::JSON#from_json` compatible with `#assign_attributes` +* Add error type support arguments to `ActiveModel::Errors#messages_for` and `ActiveModel::Errors#full_messages_for` + + ```ruby + person = Person.create() + person.errors.full_messages_for(:name, :invalid) + # => ["Name is invalid"] + + person.errors.messages_for(:name, :invalid) + # => ["is invalid"] + ``` + + *Eugene Bezludny* + +* Make `ActiveModel::Serialization#read_attribute_for_serialization` public *Sean Doyle* diff --git a/activemodel/lib/active_model/errors.rb b/activemodel/lib/active_model/errors.rb index 2a9ee0f79c5b8..e00cedfb91b15 100644 --- a/activemodel/lib/active_model/errors.rb +++ b/activemodel/lib/active_model/errors.rb @@ -414,7 +414,8 @@ def full_messages end alias :to_a :full_messages - # Returns all the full error messages for a given attribute in an array. + # Returns all the full error messages for a given attribute + # and type (optional) in an array. # # class Person # validates_presence_of :name, :email @@ -424,11 +425,15 @@ 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"] - def full_messages_for(attribute) - where(attribute).map(&:full_message).freeze + # + # person.errors.full_messages_for(:name, :invalid) + # # => ["Name is invalid"] + def full_messages_for(attribute, type = nil) + where(attribute, type).map(&:full_message).freeze end - # Returns all the error messages for a given attribute in an array. + # Returns all the error messages for a given attribute + # and type (optional) in an array. # # class Person # validates_presence_of :name, :email @@ -438,8 +443,11 @@ def full_messages_for(attribute) # person = Person.create() # person.errors.messages_for(:name) # # => ["is too short (minimum is 5 characters)", "can't be blank"] - def messages_for(attribute) - where(attribute).map(&:message) + # + # person.errors.messages_for(:name, :invalid) + # # => ["is invalid"] + def messages_for(attribute, type = nil) + where(attribute, type).map(&:message) end # Returns a full message for a given attribute. diff --git a/activemodel/test/cases/errors_test.rb b/activemodel/test/cases/errors_test.rb index 730cd347e32e3..4b815aa0cffb5 100644 --- a/activemodel/test/cases/errors_test.rb +++ b/activemodel/test/cases/errors_test.rb @@ -463,6 +463,19 @@ def test_no_key assert_raises(FrozenError) { errors.messages[:foo].clear } end + test "messages_for contains all the error messages for the given attribute" do + person = Person.new + person.errors.add(:name, :invalid) + assert_equal ["is invalid"], person.errors.messages_for(:name) + end + + test "messages_for contains all the error messages for the given attribute and type" do + person = Person.new + person.errors.add(:name, :invalid) + person.errors.add(:name, :too_long, message: "is too long") + assert_equal ["is too long"], person.errors.messages_for(:name, :too_long) + end + test "full_messages doesn't require the base object to respond to `:errors" do model = Class.new do def initialize @@ -498,6 +511,13 @@ def call assert_equal ["name cannot be blank", "name cannot be nil"], person.errors.full_messages_for(:name) end + test "full_messages_for contains all the error messages for the given attribute and type" do + person = Person.new + person.errors.add(:name, :invalid) + person.errors.add(:name, :too_long, message: "is too long") + assert_equal ["name is too long"], person.errors.full_messages_for(:name, :too_long) + end + test "full_messages_for does not contain error messages from other attributes" do person = Person.new person.errors.add(:name, "cannot be blank") From ef146a55ed32666933badc2281ea5fc359376220 Mon Sep 17 00:00:00 2001 From: Gannon McGibbon Date: Thu, 16 Oct 2025 17:22:18 -0500 Subject: [PATCH 0801/1075] Add missing structured event data for logs Related to #55900. --- .../lib/action_controller/structured_event_subscriber.rb | 5 +++++ activejob/lib/active_job/structured_event_subscriber.rb | 6 +++++- activejob/test/cases/structured_event_subscriber_test.rb | 3 +++ .../lib/active_record/structured_event_subscriber.rb | 2 +- 4 files changed, 14 insertions(+), 2 deletions(-) diff --git a/actionpack/lib/action_controller/structured_event_subscriber.rb b/actionpack/lib/action_controller/structured_event_subscriber.rb index 32bdbad8fe5c4..7bf6cf35f4c15 100644 --- a/actionpack/lib/action_controller/structured_event_subscriber.rb +++ b/actionpack/lib/action_controller/structured_event_subscriber.rb @@ -34,6 +34,7 @@ def process_action(event) controller: payload[:controller], action: payload[:action], status: status, + **additions_for(payload), duration_ms: event.duration.round(2), gc_time_ms: event.gc_time.round(1), }.compact) @@ -101,6 +102,10 @@ def fragment_cache(method_name, event) duration_ms: event.duration.round(1) ) end + + def additions_for(payload) + payload.slice(:view_runtime, :db_runtime, :queries_count, :cached_queries_count) + end end end diff --git a/activejob/lib/active_job/structured_event_subscriber.rb b/activejob/lib/active_job/structured_event_subscriber.rb index 97f2ed6216c92..d2f6ac10b7a8f 100644 --- a/activejob/lib/active_job/structured_event_subscriber.rb +++ b/activejob/lib/active_job/structured_event_subscriber.rb @@ -64,7 +64,9 @@ def enqueue_all(event) job_count: jobs.size, enqueued_count: enqueued_count, failed_enqueue_count: failed_count, - enqueued_classes: jobs.filter_map { |job| job.class.name }.tally + enqueued_classes: jobs.filter_map do |job| + job.class.name if jobs.count == enqueued_count || job.successfully_enqueued? + end.tally ) end @@ -85,10 +87,12 @@ def perform_start(event) def perform(event) job = event.payload[:job] exception = event.payload[:exception_object] + adapter = event.payload[:adapter] payload = { job_class: job.class.name, job_id: job.job_id, queue: job.queue_name, + adapter: ActiveJob.adapter_name(adapter), aborted: event.payload[:aborted], duration: event.duration.round(2), } diff --git a/activejob/test/cases/structured_event_subscriber_test.rb b/activejob/test/cases/structured_event_subscriber_test.rb index 2b3282941e232..271dbf94e47f2 100644 --- a/activejob/test/cases/structured_event_subscriber_test.rb +++ b/activejob/test/cases/structured_event_subscriber_test.rb @@ -159,6 +159,7 @@ def test_perform_failed_job end end + assert event[:payload][:exception_backtrace].is_a?(Array) assert event[:payload][:job_id].present? assert event[:payload][:duration].is_a?(Numeric) end @@ -173,6 +174,7 @@ def test_enqueue_failed_job assert_event_reported("active_job.enqueued", payload: { job_class: failing_enqueue_job_class.name, queue: "default", + adapter: ActiveJob.adapter_name(ActiveJob::Base.queue_adapter), exception_class: "StandardError", exception_message: "Enqueue failed" }) do @@ -192,6 +194,7 @@ def test_enqueue_aborted_job assert_event_reported("active_job.enqueued", payload: { job_class: aborting_enqueue_job_class.name, queue: "default", + adapter: ActiveJob.adapter_name(ActiveJob::Base.queue_adapter), aborted: true, }) do aborting_enqueue_job_class.perform_later diff --git a/activerecord/lib/active_record/structured_event_subscriber.rb b/activerecord/lib/active_record/structured_event_subscriber.rb index d2d72882cf11f..f8e027424b92a 100644 --- a/activerecord/lib/active_record/structured_event_subscriber.rb +++ b/activerecord/lib/active_record/structured_event_subscriber.rb @@ -12,7 +12,7 @@ def strict_loading_violation(event) emit_debug_event("active_record.strict_loading_violation", owner: owner.name, - class: reflection.klass.name, + class: reflection.polymorphic? ? nil : reflection.klass.name, name: reflection.name, ) end From 5cffb7cf7ef2f653d7e1b83fc944730b37a019ba Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 17 Oct 2025 01:04:19 +1100 Subject: [PATCH 0802/1075] Add environment config file existence check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit fixes the following reported issue: Fix #55779 Summary When running Rails applications with a non-existent environment, Rails will silently continue without a proper error message. Example: ```bash RAILS_ENV=NON_EXISTING_ENV bin/rails server ... ``` This commit will add the following functionality: - Enhances `load_environment_config` initializer to raise an error when there is one or many environment files in `config/environments` or any other configured location _but_ rails is unable to load any environment file in any configured location. - Adds two methods to Rails::Engine - private #any_environment_files? which returns an empty array by default to prevent Rails::Engine, aka plugins, from enforcing this functionality - private #missing_environment_file which returns nil by default to prevent Rails::Engine aka plugins to enforce this functionality - Adds two methods to Rails::Application - private #any_environment_files? which returns true if any environment file is available to be loaded in any configured location. The reason for checking existing environment files is to prevent raising an error if no environment files are available. This is useful in cases like running bug [report templates](https://github.com/rails/rails/blob/main/guides/bug_report_templates/generic.rb). - private #missing_environment_file, which raises a RuntimeError to let the user know that Rails is unable to locate the environment file. Co-authored-by: Rafael Mendonça França Co-authored-by: Jean Boussier Co-authored-by: zzak --- railties/CHANGELOG.md | 6 ++++++ railties/lib/rails/application.rb | 12 ++++++++++++ railties/lib/rails/engine.rb | 16 ++++++++++++++-- .../application/configuration/custom_test.rb | 2 +- railties/test/application/loading_test.rb | 9 +++++++++ .../test/application/middleware/cookies_test.rb | 2 +- railties/test/application/paths_test.rb | 2 +- railties/test/application/rake/dbs_test.rb | 2 +- railties/test/application/rake/framework_test.rb | 2 +- .../test/application/rake/migrations_test.rb | 2 +- railties/test/application/rake/multi_dbs_test.rb | 2 +- railties/test/commands/credentials_test.rb | 3 +++ railties/test/isolation/abstract_unit.rb | 6 ++++++ railties/test/railties/railtie_test.rb | 2 +- 14 files changed, 58 insertions(+), 10 deletions(-) diff --git a/railties/CHANGELOG.md b/railties/CHANGELOG.md index 80179304f619b..0c40563996064 100644 --- a/railties/CHANGELOG.md +++ b/railties/CHANGELOG.md @@ -1,3 +1,9 @@ +* Add environment config file existence check + + `Rails::Application` will raise an error if unable to load any environment file. + + *Daniel Niknam* + * `Rails::Application::RoutesReloader` uses the configured `Rails.application.config.file_watcher` *Jan Grodowski* diff --git a/railties/lib/rails/application.rb b/railties/lib/rails/application.rb index 44583095844b8..d8ba67ba89474 100644 --- a/railties/lib/rails/application.rb +++ b/railties/lib/rails/application.rb @@ -636,6 +636,18 @@ def ensure_generator_templates_added end private + def missing_environment_file + raise "Rails environment has been set to #{Rails.env} but config/environments/#{Rails.env}.rb does not exist." + end + + def any_environment_files? + paths["config/environments"] + .paths + .flat_map { |path| [path, *path.glob("*.rb")] } + .select(&:file?) + .any? + end + def build_request(env) req = super env["ORIGINAL_FULLPATH"] = req.fullpath diff --git a/railties/lib/rails/engine.rb b/railties/lib/rails/engine.rb index 6be63be590d20..aeda5b09a60d7 100644 --- a/railties/lib/rails/engine.rb +++ b/railties/lib/rails/engine.rb @@ -562,8 +562,14 @@ def load_seed end initializer :load_environment_config, before: :load_environment_hook, group: :all do - paths["config/environments"].existent.each do |environment| - require environment + env_files = paths["config/environments"].existent + + if env_files.empty? && any_environment_files? + missing_environment_file + else + env_files.each do |environment| + require environment + end end end @@ -687,6 +693,12 @@ def run_tasks_blocks(*) # :nodoc: end private + def missing_environment_file; end + + def any_environment_files? + false + end + def load_config_initializer(initializer) # :doc: ActiveSupport::Notifications.instrument("load_config_initializer.railties", initializer: initializer) do load(initializer) diff --git a/railties/test/application/configuration/custom_test.rb b/railties/test/application/configuration/custom_test.rb index 5158e90d0d145..1243ebac0394d 100644 --- a/railties/test/application/configuration/custom_test.rb +++ b/railties/test/application/configuration/custom_test.rb @@ -9,7 +9,7 @@ class CustomTest < ActiveSupport::TestCase def setup build_app - FileUtils.rm_rf("#{app_path}/config/environments") + reset_environment_configs end def teardown diff --git a/railties/test/application/loading_test.rb b/railties/test/application/loading_test.rb index 079861cac60a9..9a80cc45b578c 100644 --- a/railties/test/application/loading_test.rb +++ b/railties/test/application/loading_test.rb @@ -111,6 +111,15 @@ class User < ActiveRecord::Base assert ::Rails.application.config.loaded end + test "raises RuntimeError when config/environments/ENV.rb file does not exist" do + msg = <<~MSG.squish + Rails environment has been set to non_existent_environment but + config/environments/non_existent_environment.rb does not exist. + MSG + + assert_raise(RuntimeError, match: msg) { boot_app "non_existent_environment" } + end + test "descendants loaded after framework initialization are cleaned on each request if reloading is enabled" do add_to_config <<-RUBY config.enable_reloading = true diff --git a/railties/test/application/middleware/cookies_test.rb b/railties/test/application/middleware/cookies_test.rb index 90831ab916ce5..c415370f0a1f4 100644 --- a/railties/test/application/middleware/cookies_test.rb +++ b/railties/test/application/middleware/cookies_test.rb @@ -14,7 +14,7 @@ def new_app def setup build_app - FileUtils.rm_rf("#{app_path}/config/environments") + reset_environment_configs end def app diff --git a/railties/test/application/paths_test.rb b/railties/test/application/paths_test.rb index ebbc9e95822e4..8a03a10e1df40 100644 --- a/railties/test/application/paths_test.rb +++ b/railties/test/application/paths_test.rb @@ -8,7 +8,7 @@ class PathsTest < ActiveSupport::TestCase def setup build_app - FileUtils.rm_rf("#{app_path}/config/environments") + reset_environment_configs app_file "config/environments/development.rb", "" add_to_config <<-RUBY config.root = "#{app_path}" diff --git a/railties/test/application/rake/dbs_test.rb b/railties/test/application/rake/dbs_test.rb index 3d5c9ce95caf7..69a406478fc8c 100644 --- a/railties/test/application/rake/dbs_test.rb +++ b/railties/test/application/rake/dbs_test.rb @@ -10,7 +10,7 @@ class RakeDbsTest < ActiveSupport::TestCase def setup build_app - FileUtils.rm_rf("#{app_path}/config/environments") + reset_environment_configs end def teardown diff --git a/railties/test/application/rake/framework_test.rb b/railties/test/application/rake/framework_test.rb index 644b1924b52e6..211de64a6d0d1 100644 --- a/railties/test/application/rake/framework_test.rb +++ b/railties/test/application/rake/framework_test.rb @@ -9,7 +9,7 @@ class FrameworkTest < ActiveSupport::TestCase def setup build_app - FileUtils.rm_rf("#{app_path}/config/environments") + reset_environment_configs end def teardown diff --git a/railties/test/application/rake/migrations_test.rb b/railties/test/application/rake/migrations_test.rb index e2e625b85850a..529ed076e4d2b 100644 --- a/railties/test/application/rake/migrations_test.rb +++ b/railties/test/application/rake/migrations_test.rb @@ -7,7 +7,7 @@ module RakeTests class RakeMigrationsTest < ActiveSupport::TestCase def setup build_app - FileUtils.rm_rf("#{app_path}/config/environments") + reset_environment_configs add_to_config("config.active_record.timestamped_migrations = false") end diff --git a/railties/test/application/rake/multi_dbs_test.rb b/railties/test/application/rake/multi_dbs_test.rb index 45d01cf64d200..1db5729bf00ad 100644 --- a/railties/test/application/rake/multi_dbs_test.rb +++ b/railties/test/application/rake/multi_dbs_test.rb @@ -9,7 +9,7 @@ class RakeMultiDbsTest < ActiveSupport::TestCase def setup build_app(multi_db: true) - FileUtils.rm_rf("#{app_path}/config/environments") + reset_environment_configs add_to_config("config.active_record.timestamped_migrations = false") end diff --git a/railties/test/commands/credentials_test.rb b/railties/test/commands/credentials_test.rb index 2c4be413f6ae7..d8cdcaecfdb54 100644 --- a/railties/test/commands/credentials_test.rb +++ b/railties/test/commands/credentials_test.rb @@ -129,6 +129,7 @@ class Rails::Command::CredentialsTest < ActiveSupport::TestCase end test "edit command does not raise when an initializer tries to access non-existent credentials" do + File.write(app_path("config", "environments", "qa.rb"), "") app_file "config/initializers/raise_when_loaded.rb", <<-RUBY Rails.application.credentials.missing_key! RUBY @@ -286,12 +287,14 @@ class Rails::Command::CredentialsTest < ActiveSupport::TestCase end test "diff for custom environment" do + File.write(app_path("config", "environments", "custom.rb"), "") run_edit_command(environment: "custom") assert_match(/access_key_id: 123/, run_diff_command("config/credentials/custom.yml.enc")) end test "diff for custom environment when key is not available" do + File.write(app_path("config", "environments", "custom.rb"), "") run_edit_command(environment: "custom") remove_file "config/credentials/custom.key" diff --git a/railties/test/isolation/abstract_unit.rb b/railties/test/isolation/abstract_unit.rb index a1913ad497e64..a8824bfc3e26b 100644 --- a/railties/test/isolation/abstract_unit.rb +++ b/railties/test/isolation/abstract_unit.rb @@ -144,6 +144,12 @@ def build_app(options = {}) add_to_env_config :production, "config.log_level = :error" end + def reset_environment_configs + Dir["#{app_path}/config/environments/*.rb"].each do |path| + File.write(path, "") + end + end + def teardown_app ENV["RAILS_ENV"] = @prev_rails_env if @prev_rails_env Rails.app_class = @prev_rails_app_class if @prev_rails_app_class diff --git a/railties/test/railties/railtie_test.rb b/railties/test/railties/railtie_test.rb index 68b9eb3cfec18..ecb54ef6128da 100644 --- a/railties/test/railties/railtie_test.rb +++ b/railties/test/railties/railtie_test.rb @@ -8,7 +8,7 @@ class RailtieTest < ActiveSupport::TestCase def setup build_app - FileUtils.rm_rf("#{app_path}/config/environments") + reset_environment_configs require "rails/all" end From 89081b34e372afaff004d0288b7e8e94e136b4df Mon Sep 17 00:00:00 2001 From: Sean Doyle Date: Sat, 27 Sep 2025 00:32:46 -0400 Subject: [PATCH 0803/1075] `ActionController::Parameters#deconstruct_keys` for pattern matching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Related to [#48429][] Define `ActionController::Parameters#deconstruct_keys` in a `Hash`-like way, so that `params` can integrate with [pattern matching][] (for statements like `case … in`, `if … in`, and `params => { … }`). To do so, declare the [#deconstruct_keys][] method in way that loops over the specified keys and returns a `ActiveSupport::HashWithIndifferentAccess` instance with only the root-level keys. Any primitives will remain primitives, and any `ActionController::Parameters` values below the first level of nesting will remain `ActionController::Parameters` instances. This integration can be useful for controller parameters (either at the root level or nested within) that are to be passed to objects that don't expect attributes or keyword arguments: ```ruby if params in { search:, page: } Article.search(search).limit(page) else … end case (value = params[:string_or_hash_with_nested_key]) in String # do something with a String `value`… in { nested_key: } # do something with `nested_key` or `value` else # … end ``` [#48429]: https://github.com/rails/rails/issues/48429 [pattern matching]: https://docs.ruby-lang.org/en/master/syntax/pattern_matching_rdoc.html [#deconstruct_keys]: https://docs.ruby-lang.org/en/master/syntax/pattern_matching_rdoc.html#label-Matching+non-primitive+objects-3A+deconstruct+and+deconstruct_keys --- actionpack/CHANGELOG.md | 21 +++++++++++++++++++ .../metal/strong_parameters.rb | 4 ++++ .../controller/parameters/equality_test.rb | 13 ++++++++++++ 3 files changed, 38 insertions(+) diff --git a/actionpack/CHANGELOG.md b/actionpack/CHANGELOG.md index 68254e0c9b422..a6552353a9e00 100644 --- a/actionpack/CHANGELOG.md +++ b/actionpack/CHANGELOG.md @@ -1,3 +1,24 @@ +* Define `ActionController::Parameters#deconstruct_keys` to support pattern matching + + ```ruby + if params in { search:, page: } + Article.search(search).limit(page) + else + … + end + + case (value = params[:string_or_hash_with_nested_key]) + in String + # do something with a String `value`… + in { nested_key: } + # do something with `nested_key` or `value` + else + # … + end + ``` + + *Sean Doyle* + * Submit test requests using `as: :html` with `Content-Type: x-www-form-urlencoded` *Sean Doyle* diff --git a/actionpack/lib/action_controller/metal/strong_parameters.rb b/actionpack/lib/action_controller/metal/strong_parameters.rb index e81f82a90d4ac..3180e8e3b38df 100644 --- a/actionpack/lib/action_controller/metal/strong_parameters.rb +++ b/actionpack/lib/action_controller/metal/strong_parameters.rb @@ -316,6 +316,10 @@ def hash [self.class, @parameters, @permitted].hash end + def deconstruct_keys(keys) + slice(*keys).each.with_object({}) { |(key, value), hash| hash.merge!(key.to_sym => value) } + end + # Returns a safe ActiveSupport::HashWithIndifferentAccess representation of the # parameters with all unpermitted keys removed. # diff --git a/actionpack/test/controller/parameters/equality_test.rb b/actionpack/test/controller/parameters/equality_test.rb index 153794a26ad48..2c836a61480a6 100644 --- a/actionpack/test/controller/parameters/equality_test.rb +++ b/actionpack/test/controller/parameters/equality_test.rb @@ -57,4 +57,17 @@ class ParametersAccessorsTest < ActiveSupport::TestCase params = ActionController::Parameters.new(foo: { bar: "baz" }) assert params.has_value?(ActionController::Parameters.new("bar" => "baz")) end + + test "deconstruct_keys works with parameters" do + assert_pattern { @params => { person: { age: "32" } } } + refute_pattern { @params => { person: { addresses: ["does not match"] } } } + end + + test "deconstruct_keys returns instances of ActionController::Parameters for nested values" do + @params => { person: } + person => { addresses: } + + assert_kind_of ActionController::Parameters, person + assert_kind_of ActionController::Parameters, addresses.first + end end From 001ad72c91b686fcf519dfc683212b78eab31ed1 Mon Sep 17 00:00:00 2001 From: Hartley McGuire Date: Thu, 16 Oct 2025 16:08:48 -0400 Subject: [PATCH 0804/1075] Remove mention of raise_on_open_redirects from doc It has been replaced by another configuration, and its been the default since Rails 7.0, so it doesn't seem worth mentioning here. --- actionpack/lib/action_controller/metal/redirecting.rb | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/actionpack/lib/action_controller/metal/redirecting.rb b/actionpack/lib/action_controller/metal/redirecting.rb index 3dc7f92dd7064..7a949033c3f4f 100644 --- a/actionpack/lib/action_controller/metal/redirecting.rb +++ b/actionpack/lib/action_controller/metal/redirecting.rb @@ -108,10 +108,7 @@ def allowed_redirect_hosts=(hosts) # ### Open Redirect protection # # By default, Rails protects against redirecting to external hosts for your - # app's safety, so called open redirects. Note: this was a new default in Rails - # 7.0, after upgrading opt-in by uncommenting the line with - # `raise_on_open_redirects` in - # `config/initializers/new_framework_defaults_7_0.rb` + # app's safety, so called open redirects. # # Here #redirect_to automatically validates the potentially-unsafe URL: # From 50631fd9013cb5746118b316b83b3f22385ddb4f Mon Sep 17 00:00:00 2001 From: Gannon McGibbon Date: Fri, 17 Oct 2025 13:43:53 -0500 Subject: [PATCH 0805/1075] Optional event reporter-specific filter parameters Allows use of separate filter parameters for ActiveSupport.event_reporter payload filtering. Filtering will by default inherit the global filter parameters, and use specific ones if they are set. --- .../lib/active_support/event_reporter.rb | 9 ++++++- activesupport/test/event_reporter_test.rb | 26 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/activesupport/lib/active_support/event_reporter.rb b/activesupport/lib/active_support/event_reporter.rb index 0f4f8a19b7561..e8329164f6a0f 100644 --- a/activesupport/lib/active_support/event_reporter.rb +++ b/activesupport/lib/active_support/event_reporter.rb @@ -278,6 +278,9 @@ class EventReporter attr_reader :subscribers # :nodoc class << self + # Filter parameters used to filter event payloads. If nil, + # Active Support's filter parameters will be used instead. + attr_accessor :filter_parameters attr_accessor :context_store # :nodoc: end @@ -537,6 +540,10 @@ def reload_payload_filter # :nodoc: end private + def filter_parameters + self.class.filter_parameters || ActiveSupport.filter_parameters + end + def raise_on_error? @raise_on_error end @@ -548,7 +555,7 @@ def context_store def payload_filter @payload_filter ||= begin mask = ActiveSupport::ParameterFilter::FILTERED - ActiveSupport::ParameterFilter.new(ActiveSupport.filter_parameters, mask: mask) + ActiveSupport::ParameterFilter.new(filter_parameters, mask: mask) end end diff --git a/activesupport/test/event_reporter_test.rb b/activesupport/test/event_reporter_test.rb index b1018b90de229..3e961dbe79756 100644 --- a/activesupport/test/event_reporter_test.rb +++ b/activesupport/test/event_reporter_test.rb @@ -259,6 +259,32 @@ def emit(event) end end + test "default filter_parameters is used by default" do + old_filter_parameters = ActiveSupport.filter_parameters + ActiveSupport.filter_parameters = [:secret] + + assert_called_with(@subscriber, :emit, [ + event_matcher(name: "test_event", payload: { key: "value", secret: "[FILTERED]" }) + ]) do + @reporter.notify(:test_event, { key: "value", secret: "hello" }) + end + ensure + ActiveSupport.filter_parameters = old_filter_parameters + end + + test ".filter_parameters is used when present" do + old_filter_parameters = EventReporter.filter_parameters + EventReporter.filter_parameters = [:foo] + + assert_called_with(@subscriber, :emit, [ + event_matcher(name: "test_event", payload: { key: "value", foo: "[FILTERED]" }) + ]) do + @reporter.notify(:test_event, { key: "value", foo: "hello" }) + end + ensure + EventReporter.filter_parameters = old_filter_parameters + end + test "#with_debug" do @reporter.with_debug do assert_predicate @reporter, :debug_mode? From 8b43c4f635999282fb74903d5e97c7ad50165f06 Mon Sep 17 00:00:00 2001 From: zzak Date: Sat, 18 Oct 2025 08:19:18 +0900 Subject: [PATCH 0806/1075] Remove unused debug event test setup Co-authored-by: Gannon McGibbon --- railties/test/structured_event_subscriber_test.rb | 6 ------ 1 file changed, 6 deletions(-) diff --git a/railties/test/structured_event_subscriber_test.rb b/railties/test/structured_event_subscriber_test.rb index 9b357a90b2b1f..210d32e6606c4 100644 --- a/railties/test/structured_event_subscriber_test.rb +++ b/railties/test/structured_event_subscriber_test.rb @@ -8,12 +8,6 @@ module Rails class StructuredEventSubscriberTest < ActiveSupport::TestCase include ActiveSupport::Testing::EventReporterAssertions - def run(*) - ActiveSupport.event_reporter.with_debug do - super - end - end - def test_deprecation_is_notified_when_behavior_is_notify Rails.deprecator.with(behavior: :notify) do event = assert_event_reported("rails.deprecation", payload: { gem_name: "Rails" }) do From c750a3d6c124e688e7feaa7fbff4826e39f156c0 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Fri, 17 Oct 2025 14:06:05 -0400 Subject: [PATCH 0807/1075] Shard swap prohibition error does not change connected_to stack Previously, if an exception was raised due to an exception raised by a prohibited shard swap operation, a frame of the connected_to stack was popped, potentially leading to inconsistent behavior if this exception is rescued. This fixes the same issue in both `connected_to` and `connected_to_many` --- .../lib/active_record/connection_handling.rb | 20 ++++--- .../connection_swapping_nested_test.rb | 52 +++++++++++++++++++ 2 files changed, 64 insertions(+), 8 deletions(-) diff --git a/activerecord/lib/active_record/connection_handling.rb b/activerecord/lib/active_record/connection_handling.rb index cc052d954b7ba..cdadd56436f2a 100644 --- a/activerecord/lib/active_record/connection_handling.rb +++ b/activerecord/lib/active_record/connection_handling.rb @@ -173,9 +173,11 @@ def connected_to_many(*classes, role:, shard: nil, prevent_writes: false) prevent_writes = true if role == ActiveRecord.reading_role append_to_connected_to_stack(role: role, shard: shard, prevent_writes: prevent_writes, klasses: classes) - yield - ensure - connected_to_stack.pop + begin + yield + ensure + connected_to_stack.pop + end end # Passes the block to +connected_to+ for every +shard+ the @@ -396,11 +398,13 @@ def with_role_and_shard(role, shard, prevent_writes) prevent_writes = true if role == ActiveRecord.reading_role append_to_connected_to_stack(role: role, shard: shard, prevent_writes: prevent_writes, klasses: [self]) - return_value = yield - return_value.load if return_value.is_a? ActiveRecord::Relation - return_value - ensure - self.connected_to_stack.pop + begin + return_value = yield + return_value.load if return_value.is_a? ActiveRecord::Relation + return_value + ensure + self.connected_to_stack.pop + end end def append_to_connected_to_stack(entry) diff --git a/activerecord/test/cases/connection_adapters/connection_swapping_nested_test.rb b/activerecord/test/cases/connection_adapters/connection_swapping_nested_test.rb index 9066d618ecc22..778a97dc9ef3e 100644 --- a/activerecord/test/cases/connection_adapters/connection_swapping_nested_test.rb +++ b/activerecord/test/cases/connection_adapters/connection_swapping_nested_test.rb @@ -203,6 +203,58 @@ def test_shards_can_be_swapped_granularly ENV["RAILS_ENV"] = previous_env end + def test_shard_swapping_prohibition_exception_recovery + previous_env, ENV["RAILS_ENV"] = ENV["RAILS_ENV"], "default_env" + + config = { + "default_env" => { + "primary" => { "adapter" => "sqlite3", "database" => "test/db/primary.sqlite3" }, + "primary_shard_one" => { "adapter" => "sqlite3", "database" => "test/db/primary_shard_one.sqlite3" }, + "primary_shard_two" => { "adapter" => "sqlite3", "database" => "test/db/primary_shard_two.sqlite3" }, + } + } + + @prev_configs, ActiveRecord::Base.configurations = ActiveRecord::Base.configurations, config + + PrimaryBase.connects_to(shards: { + default: { writing: :primary }, + shard_one: { writing: :primary_shard_one } + }) + + global_role = :writing + + # Switch everything to default + ActiveRecord::Base.connected_to(role: global_role, shard: :default) do + assert_equal "primary", PrimaryBase.connection_pool.db_config.name + + # Switch only primary to shard_one + PrimaryBase.connected_to(shard: :shard_one) do + assert_equal "primary_shard_one", PrimaryBase.connection_pool.db_config.name + + PrimaryBase.prohibit_shard_swapping do + e = assert_raises(ArgumentError) do + PrimaryBase.connected_to(shard: :shard_two) { } + end + assert_match(/cannot swap/, e.message) + end + + assert_equal "primary_shard_one", PrimaryBase.connection_pool.db_config.name + + PrimaryBase.prohibit_shard_swapping do + e = assert_raises(ArgumentError) do + ActiveRecord::Base.connected_to_many([PrimaryBase], shard: :shard_two, role: :writing) { } + end + assert_match(/cannot swap/, e.message) + end + + assert_equal "primary_shard_one", PrimaryBase.connection_pool.db_config.name + end + end + ensure + ActiveRecord::Base.configurations = @prev_configs + ActiveRecord::Base.establish_connection(:arunit) + ENV["RAILS_ENV"] = previous_env + end def test_roles_and_shards_can_be_swapped_granularly previous_env, ENV["RAILS_ENV"] = ENV["RAILS_ENV"], "default_env" From d803405d01dbbbed349d9c69f893ed1482239fe0 Mon Sep 17 00:00:00 2001 From: Mike Dalessio Date: Sat, 18 Oct 2025 10:13:51 -0400 Subject: [PATCH 0808/1075] Raise specific exception when a prohibited shard change is attempted. The new `ShardSwapProhibitedError` exception allows applications and connection-related libraries to more easily recover from this specific scenario. Previously an `ArgumentError` was raised, so the new exception subclasses `ArgumentError` for backwards compatibility. See the original shard swap prohibition implementation in 32e2a8ef for more context. --- activerecord/CHANGELOG.md | 9 +++++++++ activerecord/lib/active_record/connection_handling.rb | 2 +- activerecord/lib/active_record/errors.rb | 6 ++++++ .../connection_handlers_sharding_db_test.rb | 2 +- .../connection_swapping_nested_test.rb | 7 +++---- 5 files changed, 20 insertions(+), 6 deletions(-) diff --git a/activerecord/CHANGELOG.md b/activerecord/CHANGELOG.md index 5bfb747860f0e..688c73467b02b 100644 --- a/activerecord/CHANGELOG.md +++ b/activerecord/CHANGELOG.md @@ -1,3 +1,12 @@ +* Raise specific exception when a prohibited shard change is attempted. + + The new `ShardSwapProhibitedError` exception allows applications and + connection-related libraries to more easily recover from this specific + scenario. Previously an `ArgumentError` was raised, so the new exception + subclasses `ArgumentError` for backwards compatibility. + + *Mike Dalessio* + * Fix SQLite3 data loss during table alterations with CASCADE foreign keys. When altering a table in SQLite3 that is referenced by child tables with diff --git a/activerecord/lib/active_record/connection_handling.rb b/activerecord/lib/active_record/connection_handling.rb index cdadd56436f2a..475e3ce3f7839 100644 --- a/activerecord/lib/active_record/connection_handling.rb +++ b/activerecord/lib/active_record/connection_handling.rb @@ -409,7 +409,7 @@ def with_role_and_shard(role, shard, prevent_writes) def append_to_connected_to_stack(entry) if shard_swapping_prohibited? && entry[:shard].present? - raise ArgumentError, "cannot swap `shard` while shard swapping is prohibited." + raise ShardSwapProhibitedError, "cannot swap `shard` while shard swapping is prohibited." end connected_to_stack << entry diff --git a/activerecord/lib/active_record/errors.rb b/activerecord/lib/active_record/errors.rb index c4530d5a57902..9cdf77e2e8645 100644 --- a/activerecord/lib/active_record/errors.rb +++ b/activerecord/lib/active_record/errors.rb @@ -131,6 +131,12 @@ class ExclusiveConnectionTimeoutError < ConnectionTimeoutError class ReadOnlyError < ActiveRecordError end + # Raised when shard swapping is attempted on a connection that prohibits it. + # See {ActiveRecord::ConnectionHandling#prohibit_shard_swapping}[rdoc-ref:ConnectionHandling#prohibit_shard_swapping]. + class ShardSwapProhibitedError < ArgumentError + # This subclasses ArgumentError for backwards compatibility. + end + # Raised when Active Record cannot find a record by given id or set of ids. class RecordNotFound < ActiveRecordError attr_reader :model, :primary_key, :id 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 0d70f2d6e6ca5..ebcc77f77fdf5 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 @@ -358,7 +358,7 @@ def test_cannot_swap_shards_while_prohibited shard_one: { writing: :primary_shard_one } }) - assert_raises(ArgumentError) do + assert_raises(ShardSwapProhibitedError) do ActiveRecord::Base.prohibit_shard_swapping do ActiveRecord::Base.connected_to(role: :reading, shard: :default) do end diff --git a/activerecord/test/cases/connection_adapters/connection_swapping_nested_test.rb b/activerecord/test/cases/connection_adapters/connection_swapping_nested_test.rb index 778a97dc9ef3e..841b1cb91d882 100644 --- a/activerecord/test/cases/connection_adapters/connection_swapping_nested_test.rb +++ b/activerecord/test/cases/connection_adapters/connection_swapping_nested_test.rb @@ -232,19 +232,17 @@ def test_shard_swapping_prohibition_exception_recovery assert_equal "primary_shard_one", PrimaryBase.connection_pool.db_config.name PrimaryBase.prohibit_shard_swapping do - e = assert_raises(ArgumentError) do + assert_raises(ShardSwapProhibitedError) do PrimaryBase.connected_to(shard: :shard_two) { } end - assert_match(/cannot swap/, e.message) end assert_equal "primary_shard_one", PrimaryBase.connection_pool.db_config.name PrimaryBase.prohibit_shard_swapping do - e = assert_raises(ArgumentError) do + assert_raises(ShardSwapProhibitedError) do ActiveRecord::Base.connected_to_many([PrimaryBase], shard: :shard_two, role: :writing) { } end - assert_match(/cannot swap/, e.message) end assert_equal "primary_shard_one", PrimaryBase.connection_pool.db_config.name @@ -255,6 +253,7 @@ def test_shard_swapping_prohibition_exception_recovery ActiveRecord::Base.establish_connection(:arunit) ENV["RAILS_ENV"] = previous_env end + def test_roles_and_shards_can_be_swapped_granularly previous_env, ENV["RAILS_ENV"] = ENV["RAILS_ENV"], "default_env" From b558cc35ecaa0cc184ed05f7254db37c4318ac5c Mon Sep 17 00:00:00 2001 From: Aidan Haran Date: Sat, 18 Oct 2025 20:13:35 +0100 Subject: [PATCH 0809/1075] Use truthy condition in instead of true for MSSQL support --- activerecord/test/cases/relation/with_test.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/activerecord/test/cases/relation/with_test.rb b/activerecord/test/cases/relation/with_test.rb index 6b16dfd086af7..f6b78334c4b10 100644 --- a/activerecord/test/cases/relation/with_test.rb +++ b/activerecord/test/cases/relation/with_test.rb @@ -28,8 +28,8 @@ def test_with_when_hash_is_passed_as_an_argument def test_with_when_hash_with_multiple_elements_of_different_type_is_passed_as_an_argument cte_options = { posts_with_tags: Post.arel_table.project(Arel.star).where(Post.arel_table[:tags_count].gt(0)), - posts_with_tags_and_true: Arel.sql("SELECT * FROM posts_with_tags WHERE true"), - posts_with_tags_and_comments: Arel.sql("SELECT * FROM posts_with_tags_and_true WHERE tags_count > ?", 0), + posts_with_tags_and_truthy: Arel.sql("SELECT * FROM posts_with_tags WHERE 1=1"), + posts_with_tags_and_comments: Arel.sql("SELECT * FROM posts_with_tags_and_truthy WHERE tags_count > ?", 0), "posts_with_tags_and_multiple_comments" => Post.where("legacy_comments_count > 1").from("posts_with_tags_and_comments AS posts") } relation = Post.with(cte_options).from("posts_with_tags_and_multiple_comments AS posts") From 1885b980fd2846debcbe6d5dd349519d2823d27f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Sat, 18 Oct 2025 20:43:08 +0000 Subject: [PATCH 0810/1075] Update sdoc to 2.6.5 This fix the favicon on the api website. --- Gemfile.lock | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 29b83204b441b..15511464c0b14 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -193,7 +193,7 @@ GEM dotenv (3.1.7) drb (2.2.3) ed25519 (1.3.0) - erb (5.0.2) + erb (5.1.1) erubi (1.13.1) et-orbi (1.2.11) tzinfo @@ -487,9 +487,10 @@ GEM rb-inotify (0.11.1) ffi (~> 1.0) rbtree (0.4.6) - rdoc (6.14.2) + rdoc (6.15.0) erb psych (>= 4.0.0) + tsort redcarpet (3.6.1) redis (5.4.1) redis-client (>= 0.22.0) @@ -583,7 +584,7 @@ GEM google-protobuf (~> 4.29) sass-embedded (1.83.4-x86_64-linux-musl) google-protobuf (~> 4.29) - sdoc (2.6.4) + sdoc (2.6.5) rdoc (>= 5.0) securerandom (0.4.1) selenium-webdriver (4.32.0) @@ -832,4 +833,4 @@ DEPENDENCIES websocket-client-simple BUNDLED WITH - 2.7.0 + 2.7.2 From 3ad7ac64d72a406063ccf9a31afa3ef45380779b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Sat, 18 Oct 2025 21:56:40 +0000 Subject: [PATCH 0811/1075] Use NPM trusted publishing for our NPM packages This is the most secure way to publish NPM packages. `--provenance` is the default when Trusted Publishing is used. See https://docs.npmjs.com/trusted-publishers --- .github/workflows/release.yml | 5 +++-- tools/releaser/lib/releaser.rb | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cf956d56ed119..47da6d936a9d9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,6 +26,9 @@ jobs: registry-url: 'https://registry.npmjs.org' - name: Configure trusted publishing credentials uses: rubygems/configure-rubygems-credentials@v1.0.0 + # Ensure npm 11.5.1 or later is installed + - name: Update npm + run: npm install -g npm@latest - name: Bundle install run: bundle install working-directory: tools/releaser @@ -33,8 +36,6 @@ jobs: run: bundle exec rake push shell: bash working-directory: tools/releaser - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Wait for release to propagate run: gem exec rubygems-await pkg/*.gem shell: bash diff --git a/tools/releaser/lib/releaser.rb b/tools/releaser/lib/releaser.rb index a33ea1b55bdb0..c583ee0ef0747 100644 --- a/tools/releaser/lib/releaser.rb +++ b/tools/releaser/lib/releaser.rb @@ -311,7 +311,7 @@ def inexistent_tag? def npm_otp " --otp " + ykman("npmjs.com") rescue - " --provenance --access public" + " --access public" end def gem_otp(gem_path) From 9eeeed612645c8c154d3682cab2a97e2736aad36 Mon Sep 17 00:00:00 2001 From: Jerome Dalbert Date: Sun, 19 Oct 2025 00:19:58 -0700 Subject: [PATCH 0812/1075] Remove extra blank lines in ci.yml --- .../rails/generators/rails/app/templates/github/ci.yml.tt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/railties/lib/rails/generators/rails/app/templates/github/ci.yml.tt b/railties/lib/rails/generators/rails/app/templates/github/ci.yml.tt index 05229cd809a68..2a11d782ec625 100644 --- a/railties/lib/rails/generators/rails/app/templates/github/ci.yml.tt +++ b/railties/lib/rails/generators/rails/app/templates/github/ci.yml.tt @@ -18,16 +18,16 @@ jobs: uses: ruby/setup-ruby@v1 with: bundler-cache: true + <%- unless skip_brakeman? -%> - <%- unless skip_brakeman? %> - name: Scan for common Rails security vulnerabilities using static analysis run: bin/brakeman --no-pager - <% end %> - + <% end -%> <%- unless skip_bundler_audit? -%> + - name: Scan for known security vulnerabilities in gems used run: bin/bundler-audit - <% end %> + <% end -%> <% end -%> <%- if using_importmap? -%> From 720968aeffe1878f1aead1d07bd97e2a3e9efe53 Mon Sep 17 00:00:00 2001 From: Hartley McGuire Date: Sun, 19 Oct 2025 11:52:01 -0400 Subject: [PATCH 0813/1075] Prefer Adapter#schema_cache to Model#schema_cache Model#schema_cache has to lookup the current pool, but we already have a connection/pool inside InsertAll, so this lookup is not needed. This lookup currently happens 6 times inside InsertAll, and removing them all saves about ~3.5% of time on the `/updates` TechEmpower benchmark when using a SQLite memory database. --- activerecord/lib/active_record/insert_all.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/activerecord/lib/active_record/insert_all.rb b/activerecord/lib/active_record/insert_all.rb index a2b281fc94f33..9fbcb2d9ce7de 100644 --- a/activerecord/lib/active_record/insert_all.rb +++ b/activerecord/lib/active_record/insert_all.rb @@ -59,7 +59,7 @@ def updatable_columns end def primary_keys - Array(@model.schema_cache.primary_keys(model.table_name)) + Array(@connection.schema_cache.primary_keys(model.table_name)) end def skip_duplicates? @@ -167,7 +167,7 @@ def find_unique_index_for(unique_by) end def unique_indexes - @model.schema_cache.indexes(model.table_name).select(&:unique) + @connection.schema_cache.indexes(model.table_name).select(&:unique) end def ensure_valid_options_for_connection! From b7f8023fb28870e438b2a27e419b5b6aa5756954 Mon Sep 17 00:00:00 2001 From: claudiob Date: Sun, 19 Oct 2025 23:01:29 -0700 Subject: [PATCH 0814/1075] Add blank line after create_enum in db/schema.rb Separate PostgreSQL's `create_enum` from the first `create_table` with a new line in any generated db/schema.rb file. This follows the pattern already in place to separate other commands such as `enable_extension`: ```ruby ActiveRecord::Schema[8.2].define(version: 0) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" enable_extension "pgcrypto" enable_extension "uuid-ossp" # Custom types defined in this database. # Note that some types may not work with other database engines. Be careful if changing database. create_enum "enum_with_comma", ["value1", "value,2", "value3"] create_table "1_need_quoting", force: :cascade do |t| t.string "name" end ... ``` Before this commit there was no blank line between `create_enum` and `create_table`. --- .../connection_adapters/postgresql/schema_dumper.rb | 1 + 1 file changed, 1 insertion(+) 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 fdb4b8fcaf3b8..550516c5fdf0f 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/schema_dumper.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/schema_dumper.rb @@ -42,6 +42,7 @@ def types(stream) types.sort.each do |name, values| stream.puts " create_enum #{relation_name(name).inspect}, #{values.inspect}" end + stream.puts end end end From 67b0e320b68c7c3404d58b59f6c7063c17367c7b Mon Sep 17 00:00:00 2001 From: zzak Date: Mon, 20 Oct 2025 21:47:43 +0900 Subject: [PATCH 0815/1075] Set podman user as root so thruster can start --- .github/workflows/rails-new-docker.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/rails-new-docker.yml b/.github/workflows/rails-new-docker.yml index 79bf48d25266a..082ead2216ff8 100644 --- a/.github/workflows/rails-new-docker.yml +++ b/.github/workflows/rails-new-docker.yml @@ -34,6 +34,7 @@ jobs: - name: Run container run: | podman run --name $APP_NAME \ + --user root \ -v $(pwd):$(pwd) \ -e SECRET_KEY_BASE_DUMMY=1 \ -e DATABASE_URL=sqlite3:storage/production.sqlite3 \ From 9a5c073b452c8997c5b1b1dbeaa3edad70925576 Mon Sep 17 00:00:00 2001 From: Mari Imaizumi Date: Mon, 20 Oct 2025 22:41:07 +0900 Subject: [PATCH 0816/1075] Revert "Rewrite confusing code for getting association class" This reverts commit 2fe2cd4bcd52ea3bd3a534b766b07f84c52f6467. --- activerecord/lib/active_record/reflection.rb | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/activerecord/lib/active_record/reflection.rb b/activerecord/lib/active_record/reflection.rb index 2c6ad15e152fc..954f01b1b2734 100644 --- a/activerecord/lib/active_record/reflection.rb +++ b/activerecord/lib/active_record/reflection.rb @@ -425,14 +425,10 @@ def klass def _klass(class_name) # :nodoc: if active_record.name.demodulize == class_name - begin - compute_class("::#{class_name}") - rescue NameError - compute_class(class_name) - end - else - compute_class(class_name) + return compute_class("::#{class_name}") rescue NameError end + + compute_class(class_name) end def compute_class(name) From aa0eb916a4b05ec93ea681413e3999491d693863 Mon Sep 17 00:00:00 2001 From: Mari Imaizumi Date: Mon, 20 Oct 2025 22:47:11 +0900 Subject: [PATCH 0817/1075] Add regression test for reflection fallback on name conflict This commit adds a regression test. It reproduces the case where: 1. A nested ActiveRecord model exists (e.g., `Nested::Child`). 2. A top-level constant (e.g., `module Child`) exists with the same demodulized name, but it is *not* an `ActiveRecord::Base` subclass. --- activerecord/test/cases/reflection_test.rb | 12 ++++++++++++ activerecord/test/models/user.rb | 6 ++++++ 2 files changed, 18 insertions(+) diff --git a/activerecord/test/cases/reflection_test.rb b/activerecord/test/cases/reflection_test.rb index 1b1b73de27457..9bc095e671654 100644 --- a/activerecord/test/cases/reflection_test.rb +++ b/activerecord/test/cases/reflection_test.rb @@ -221,6 +221,18 @@ def test_reflection_klass_with_same_modularized_name assert_equal Nested::NestedUser, reflection.klass end + def test_reflection_klass_for_nested_association_with_top_level_module + reflection = ActiveRecord::Reflection.create( + :has_many, + :children, + nil, + {}, + Nested::Child + ) + + assert_equal Nested::Child, reflection.klass + end + def test_aggregation_reflection reflection_for_address = AggregateReflection.new( :address, nil, { mapping: [ %w(address_street street), %w(address_city city), %w(address_country country) ] }, Customer diff --git a/activerecord/test/models/user.rb b/activerecord/test/models/user.rb index c83d82cb34eb8..6779670a2c5fd 100644 --- a/activerecord/test/models/user.rb +++ b/activerecord/test/models/user.rb @@ -27,6 +27,9 @@ class UserWithNotification < User after_create -> { Notification.create! message: "A new user has been created." } end +module Child +end + module Nested class User < ActiveRecord::Base self.table_name = "users" @@ -35,4 +38,7 @@ class User < ActiveRecord::Base class NestedUser < ActiveRecord::Base has_many :nested_users end + + class Child < ActiveRecord::Base + end end From 35ae36279d4a89f6de264440f1c0c993885d4896 Mon Sep 17 00:00:00 2001 From: Chris Oliver Date: Mon, 20 Oct 2025 10:26:22 -0500 Subject: [PATCH 0818/1075] Link to next tutorial in What's Next --- guides/source/getting_started.md | 10 +--------- guides/source/sign_up_and_settings.md | 6 +----- guides/source/wishlists.md | 3 +++ 3 files changed, 5 insertions(+), 14 deletions(-) diff --git a/guides/source/getting_started.md b/guides/source/getting_started.md index b1e42f15055a9..fe1b2a0bbd133 100644 --- a/guides/source/getting_started.md +++ b/guides/source/getting_started.md @@ -2959,15 +2959,7 @@ What's Next? Congratulations on building and deploying your first Rails application! -We recommend continuing to add features and deploy updates to continue learning. -Here are some ideas: - -* Improve the design with CSS -* Add product reviews -* Finish translating the app into another language -* Add a checkout flow for payments -* Add wishlists for users to save products -* Add a carousel for product images +Next, follow the [Sign Up and Settings tutorial](sign_up_and_settings.html) to continue learning. We also recommend learning more by reading other Ruby on Rails Guides: diff --git a/guides/source/sign_up_and_settings.md b/guides/source/sign_up_and_settings.md index 905abdd77a6e2..7aa75f4f9c2d3 100644 --- a/guides/source/sign_up_and_settings.md +++ b/guides/source/sign_up_and_settings.md @@ -1999,11 +1999,7 @@ What's Next You did it! Your e-commerce store now supports user sign up, account management, and an admin area for managing products and users. -Here are a few ideas to build on to this: - -- Add shareable wishlists -- Write more tests to ensure the application works correctly -- Add payments to buy products +Next, follow the [Wishlists tutorial](wishlists.html) to continue learning. Happy building! diff --git a/guides/source/wishlists.md b/guides/source/wishlists.md index 2b8a323a90981..df84a90c3190a 100644 --- a/guides/source/wishlists.md +++ b/guides/source/wishlists.md @@ -1651,6 +1651,9 @@ Here are a few ideas to build on to this: - Add product reviews - Write more tests +- Finish translating the app into another language +- Add a carousel for product images +- Improve the design with CSS - Add payments to buy products [Return to all tutorials](https://rubyonrails.org/docs/tutorials) From e8dce052078a68ac2f41bcb480a57869800d443a Mon Sep 17 00:00:00 2001 From: Chris Oliver Date: Mon, 20 Oct 2025 12:13:10 -0500 Subject: [PATCH 0819/1075] Explain flash better in Getting Started guide Co-authored-by: Harsh --- guides/source/getting_started.md | 33 ++++++++++++++++++++++----- guides/source/sign_up_and_settings.md | 29 ++++++++--------------- 2 files changed, 36 insertions(+), 26 deletions(-) diff --git a/guides/source/getting_started.md b/guides/source/getting_started.md index 1a56b212e08c7..da4233f80eee6 100644 --- a/guides/source/getting_started.md +++ b/guides/source/getting_started.md @@ -1348,8 +1348,14 @@ We also want to replace any instance variables with a local variable, which we can define when we render the partial. We'll do this by replacing `@product` with `product`. -```erb#1 +Let's also display any errors from the form submission inside the form. + +```erb#1-4 <%= form_with model: product do |form| %> + <% if form.object.errors.any? %> +

<%= form.object.errors.full_messages.first %>

+ <% end %> +
<%= form.label :name %> <%= form.text_field :name %> @@ -2113,22 +2119,32 @@ class SubscribersController < ApplicationController end ``` -Our redirect sets a notice in the Rails flash. The flash is used for storing -messages to display on the next page. +The `redirect_to` uses the `notice:` argument to set a "flash" message to tell +the user they are subscribed. + +The [flash](https://api.rubyonrails.org/classes/ActionDispatch/Flash.html) +provides a way to pass temporary data between controller actions. Anything you +place in the flash will be available to the very next action and then cleared. +The flash is typically used for setting messages (e.g. notices and alerts) in a +controller action before redirecting to an action that displays the message to +the user. -To display the flash message, let's add the notice to +To display the flash message, let's add the flash to `app/views/layouts/application.html.erb` inside the body: -```erb#4 +```erb#4-5 -
<%= notice %>
+
<%= flash[:notice] %>
+
<%= flash[:alert] %>
``` +Learn more about the Flash in the [Action Controller Overview](action_controller_overview.html#the-flash) + To subscribe users to a specific product, we'll use a nested route so we know which product the subscriber belongs to. In `config/routes.rb` change `resources :products` to the following: @@ -2508,6 +2524,11 @@ main { margin: 0 auto; } +.alert, +.error { + color: red; +} + .notice { color: green; } diff --git a/guides/source/sign_up_and_settings.md b/guides/source/sign_up_and_settings.md index 905abdd77a6e2..bd02e474bd210 100644 --- a/guides/source/sign_up_and_settings.md +++ b/guides/source/sign_up_and_settings.md @@ -498,10 +498,9 @@ You can now visit http://localhost:3000/settings/profile to update your name. Let's update the navigation to include a link to Settings next to the Log out button. -Open `app/views/layouts/application.html.erb` and update the navbar. We'll also -add a div for any alert messages from our controllers while we're here. +Open `app/views/layouts/application.html.erb` and update the navbar. -```erb#9,13-19 +```erb#13-19 @@ -509,8 +508,8 @@ add a div for any alert messages from our controllers while we're here. -
<%= notice %>
-
<%= alert %>
+
<%= flash[:notice] %>
+
<%= flash[:alert] %>