├── .standard.yml ├── sig ├── validation_errors.rbs └── lib │ └── validation_errors.rbs ├── Gemfile ├── .gitignore ├── bin ├── setup └── console ├── lib ├── flat_validation_error.rb ├── generators │ ├── templates │ │ ├── create_flat_validation_errors.rb.tt │ │ ├── flat_validation_errors_v01.sql.tt │ │ └── create_validation_errors_table.rb.tt │ └── validation_errors │ │ └── install_generator.rb ├── validation_error.rb └── validation_errors.rb ├── Rakefile ├── .semaphore ├── main-deploy.yml └── semaphore.yml ├── CHANGELOG.md ├── .github └── workflows │ └── test.yml ├── validation_errors.gemspec ├── Gemfile.lock ├── test ├── test_helper.rb └── test_validation_error.rb └── README.md /.standard.yml: -------------------------------------------------------------------------------- 1 | # For available configuration options, see: 2 | # https://github.com/testdouble/standard 3 | ruby_version: 2.6 4 | -------------------------------------------------------------------------------- /sig/validation_errors.rbs: -------------------------------------------------------------------------------- 1 | module ValidationErrors 2 | VERSION: String 3 | # See the writing guide of rbs: https://github.com/ruby/rbs#guides 4 | end 5 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | # Specify your gem's dependencies in validation_errors.gemspec 6 | gemspec 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | *.sqlite3 10 | *.gem 11 | /db/*.sqlite3* 12 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /lib/flat_validation_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class FlatValidationError < ActiveRecord::Base 4 | def readonly? 5 | true 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/generators/templates/create_flat_validation_errors.rb.tt: -------------------------------------------------------------------------------- 1 | class CreateFlatValidationErrors < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>] 2 | def change 3 | create_view :flat_validation_errors 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | require "rake/testtask" 5 | 6 | Rake::TestTask.new(:test) do |t| 7 | t.libs << "test" 8 | t.libs << "lib" 9 | t.test_files = FileList["test/**/test_*.rb"] 10 | end 11 | 12 | require "standard/rake" 13 | 14 | task default: %i[test standard] 15 | -------------------------------------------------------------------------------- /lib/generators/templates/flat_validation_errors_v01.sql.tt: -------------------------------------------------------------------------------- 1 | select validation_errors.invalid_model_name, 2 | validation_errors.invalid_model_id, 3 | validation_errors.action, 4 | validation_errors.created_at, 5 | json_data.key as error_column, 6 | json_array_elements(json_data.value)->>'error' as error_type 7 | from validation_errors, json_each(validation_errors.details) as json_data 8 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "bundler/setup" 5 | require "validation_errors" 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | # (If you use this, don't forget to add pry to your Gemfile!) 11 | # require "pry" 12 | # Pry.start 13 | 14 | require "irb" 15 | IRB.start(__FILE__) 16 | -------------------------------------------------------------------------------- /lib/generators/templates/create_validation_errors_table.rb.tt: -------------------------------------------------------------------------------- 1 | class CreateValidationErrorsTable < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>] 2 | def change 3 | create_table :validation_errors do |t| 4 | t.string :invalid_model_name, index: true 5 | t.bigint :invalid_model_id 6 | t.string :action, index: true 7 | t.json :details 8 | t.timestamps null: false 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /.semaphore/main-deploy.yml: -------------------------------------------------------------------------------- 1 | version: v1.0 2 | name: main-deploy 3 | agent: 4 | machine: 5 | type: e1-standard-2 6 | os_image: ubuntu1804 7 | 8 | blocks: 9 | - name: main-deploy 10 | task: 11 | secrets: 12 | - name: rubygems-deploy 13 | jobs: 14 | - name: main-deploy 15 | commands: 16 | - checkout --use-cache 17 | - gem build validation_errors 18 | - gem push validation_errors-*.gem 19 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [0.3.1] - 2025-08-12 2 | 3 | - Documentation changes and some more tests to specify how to track errors on nested associations. 4 | 5 | ## [0.3.0] - 2024-11-05 6 | 7 | - Fix persistance in case of rolled back transactions. 8 | - Filter `Rails.application.config.filter_parameters` by default. Does not support procs. 9 | 10 | ## [0.2.0] - 2022-12-29 11 | 12 | - Allow to use a custom action name when tracking errors. 13 | 14 | ## [0.1.0] - 2022-09-19 15 | 16 | - Initial release. 17 | -------------------------------------------------------------------------------- /sig/lib/validation_errors.rbs: -------------------------------------------------------------------------------- 1 | class ValidationErrors < ActiveRecord::Base 2 | def self.track: (untyped invalid_model) -> untyped 3 | 4 | module Trackable 5 | def self.included: (untyped base) -> untyped 6 | 7 | # :nodoc: 8 | module ClassMethods 9 | def track_validation_errors: () -> untyped 10 | end 11 | 12 | module InstanceMethods 13 | def save: (**untyped options) -> untyped 14 | 15 | # Attempts to save the record just like {ActiveRecord::Base#save}[rdoc-ref:Base#save] but 16 | # will raise an ActiveRecord::RecordInvalid exception instead of returning +false+ if the record is not valid. 17 | def save!: (**untyped options) -> untyped 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test & lint 2 | on: [push] 3 | 4 | jobs: 5 | tests: 6 | name: Test 7 | 8 | strategy: 9 | fail-fast: false 10 | matrix: 11 | os: [ubuntu-latest] 12 | ruby: [3.1] 13 | 14 | runs-on: ${{ matrix.os }} 15 | 16 | steps: 17 | - name: Checkout code 18 | uses: actions/checkout@v4 19 | 20 | - name: Set up Ruby 21 | uses: ruby/setup-ruby@v1 22 | with: 23 | ruby-version: ${{ matrix.ruby }} 24 | bundler-cache: true 25 | 26 | - name: Install dependencies 27 | run: bundle install --jobs 4 --retry 3 28 | 29 | - name: Run tests 30 | run: bundle exec rake test 31 | 32 | - name: Run linters 33 | run: bundle exec standardrb 34 | -------------------------------------------------------------------------------- /.semaphore/semaphore.yml: -------------------------------------------------------------------------------- 1 | version: "v1.0" 2 | name: validation_errors 3 | agent: 4 | machine: 5 | type: e1-standard-2 6 | os_image: ubuntu1804 7 | auto_cancel: 8 | running: 9 | when: "true" 10 | 11 | blocks: 12 | - name: tests 13 | execution_time_limit: 14 | minutes: 10 15 | task: 16 | secrets: 17 | - name: validation_errors 18 | prologue: 19 | commands: 20 | - checkout --use-cache 21 | - cache restore 22 | - bundle config set path 'vendor/bundle' 23 | - bundle install -j 4 24 | - cache store 25 | jobs: 26 | - name: tests 27 | commands: 28 | - bundle exec standardrb 29 | - bundle exec rake 30 | promotions: 31 | - name: main 32 | pipeline_file: main-deploy.yml 33 | -------------------------------------------------------------------------------- /lib/validation_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ValidationError < ActiveRecord::Base 4 | def self.track(invalid_model, action: invalid_model.persisted? ? "update" : "create") 5 | details = filter_sensible_information(invalid_model.errors.details) 6 | create!(invalid_model_name: invalid_model.class.name, 7 | invalid_model_id: invalid_model.id, 8 | details: details, 9 | action: action) 10 | end 11 | 12 | def self.filter_sensible_information(details) 13 | filter_parameters = if defined?(Rails) && Rails.respond_to?(:application) 14 | Rails.application.config.filter_parameters 15 | else 16 | [] 17 | end 18 | filtered_details = details.dup 19 | filtered_details.each do |column_name, errors| 20 | filter_parameters.each do |filter| 21 | must_filter = case filter 22 | when Regexp 23 | filter.match?(column_name) 24 | when String, Symbol 25 | filter.to_s == column_name.to_s 26 | end 27 | if must_filter 28 | errors.each do |error| 29 | if error[:value].present? 30 | error[:value] = "***" 31 | end 32 | end 33 | end 34 | end 35 | end 36 | filtered_details 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/generators/validation_errors/install_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rails/generators/base" 4 | require "rails/generators/migration" 5 | 6 | module ValidationErrors 7 | module Generators 8 | class InstallGenerator < Rails::Generators::Base 9 | include Rails::Generators::Migration 10 | 11 | source_root File.expand_path("../../templates", __FILE__) 12 | 13 | # Implement the required interface for Rails::Generators::Migration. 14 | def self.next_migration_number(dirname) 15 | next_migration_number = current_migration_number(dirname) + 1 16 | ActiveRecord::Migration.next_migration_number(next_migration_number) 17 | end 18 | 19 | desc "Copy migrations to your application." 20 | def copy_migrations 21 | migration_template "create_validation_errors_table.rb", "db/migrate/create_validation_errors_table.rb" 22 | if defined?(Scenic) 23 | migration_template "create_flat_validation_errors.rb", "db/migrate/create_flat_validation_errors.rb" 24 | copy_file "flat_validation_errors_v01.sql", "db/views/flat_validation_errors_v01.sql" 25 | else 26 | puts "Scenic is not installed so we will skip the creation of the flat_validation_errors view.\nCheck the README for more information." 27 | end 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/validation_errors.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_record" 4 | require "zeitwerk" 5 | 6 | loader = Zeitwerk::Loader.for_gem(warn_on_extra_files: false) 7 | loader.collapse("#{__dir__}/validation_errors") 8 | loader.ignore("#{__dir__}/generators") 9 | loader.setup 10 | 11 | module ValidationErrors 12 | module Trackable 13 | def self.included(base) 14 | base.extend ClassMethods 15 | end 16 | 17 | # :nodoc: 18 | module ClassMethods 19 | def track_validation_errors 20 | include InstanceMethods 21 | end 22 | end 23 | 24 | module InstanceMethods 25 | def update(attributes) 26 | super.tap do |result| 27 | ValidationError.track(self) unless result 28 | end 29 | end 30 | 31 | def update!(attributes) 32 | super 33 | rescue ActiveRecord::RecordInvalid 34 | ValidationError.track(self) 35 | raise 36 | end 37 | 38 | def save(**options) 39 | super.tap do |result| 40 | ValidationError.track(self) unless result || persisted? 41 | end 42 | end 43 | 44 | # Attempts to save the record just like {ActiveRecord::Base#save}[rdoc-ref:Base#save] but 45 | # will raise an ActiveRecord::RecordInvalid exception instead of returning +false+ if the record is not valid. 46 | def save!(**options) 47 | super 48 | rescue ActiveRecord::RecordInvalid 49 | ValidationError.track(self) unless persisted? 50 | raise 51 | end 52 | end 53 | end 54 | end 55 | 56 | ActiveRecord::Base.include ValidationErrors::Trackable 57 | -------------------------------------------------------------------------------- /validation_errors.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Gem::Specification.new do |spec| 4 | spec.name = "validation_errors" 5 | spec.version = "0.3.1" 6 | spec.authors = ["Alessandro Rodi"] 7 | spec.email = ["rodi@hey.com"] 8 | 9 | spec.summary = "Track ActiveRecord validation errors on database" 10 | spec.description = "Easily track all the validation errors on your database so that you can analyse them." 11 | spec.homepage = "https://github.com/coorasse/validation_errors" 12 | spec.license = "MIT" 13 | spec.required_ruby_version = ">= 2.6.0" 14 | 15 | spec.metadata["homepage_uri"] = spec.homepage 16 | spec.metadata["source_code_uri"] = "https://github.com/coorasse/validation_errors" 17 | spec.metadata["changelog_uri"] = "https://github.com/coorasse/validation_errors/blob/main/CHANGELOG.md" 18 | spec.metadata["funding_uri"] = "https://github.com/sponsors/coorasse" 19 | 20 | # Specify which files should be added to the gem when it is released. 21 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git. 22 | spec.files = Dir.chdir(File.expand_path(__dir__)) do 23 | `git ls-files -z`.split("\x0").reject do |f| 24 | (f == __FILE__) || f.match(%r{\A(?:(?:bin|test|spec|features)/|\.(?:git|travis|circleci)|appveyor)}) 25 | end 26 | end 27 | spec.bindir = "exe" 28 | spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } 29 | spec.require_paths = ["lib"] 30 | 31 | # Uncomment to register a new dependency of your gem 32 | spec.add_dependency "activerecord", ">= 4.1.0" 33 | spec.add_dependency "zeitwerk", ">= 2.0.0" 34 | 35 | spec.add_development_dependency "sqlite3", "~> 1.5.0" 36 | spec.add_development_dependency "standard", "~> 1.31.0" 37 | spec.add_development_dependency "rake", "~> 13.0" 38 | spec.add_development_dependency "minitest", "~> 5.0" 39 | end 40 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | validation_errors (0.3.1) 5 | activerecord (>= 4.1.0) 6 | zeitwerk (>= 2.0.0) 7 | 8 | GEM 9 | remote: https://rubygems.org/ 10 | specs: 11 | activemodel (7.2.2.1) 12 | activesupport (= 7.2.2.1) 13 | activerecord (7.2.2.1) 14 | activemodel (= 7.2.2.1) 15 | activesupport (= 7.2.2.1) 16 | timeout (>= 0.4.0) 17 | activesupport (7.2.2.1) 18 | base64 19 | benchmark (>= 0.3) 20 | bigdecimal 21 | concurrent-ruby (~> 1.0, >= 1.3.1) 22 | connection_pool (>= 2.2.5) 23 | drb 24 | i18n (>= 1.6, < 2) 25 | logger (>= 1.4.2) 26 | minitest (>= 5.1) 27 | securerandom (>= 0.3) 28 | tzinfo (~> 2.0, >= 2.0.5) 29 | ast (2.4.3) 30 | base64 (0.1.2) 31 | benchmark (0.4.1) 32 | bigdecimal (3.2.2) 33 | concurrent-ruby (1.3.5) 34 | connection_pool (2.5.3) 35 | drb (2.2.3) 36 | i18n (1.14.7) 37 | concurrent-ruby (~> 1.0) 38 | json (2.13.2) 39 | language_server-protocol (3.17.0.5) 40 | lint_roller (1.1.0) 41 | logger (1.7.0) 42 | minitest (5.25.5) 43 | parallel (1.27.0) 44 | parser (3.3.9.0) 45 | ast (~> 2.4.1) 46 | racc 47 | prism (1.4.0) 48 | racc (1.8.1) 49 | rainbow (3.1.1) 50 | rake (13.3.0) 51 | regexp_parser (2.11.1) 52 | rexml (3.4.1) 53 | rubocop (1.56.4) 54 | base64 (~> 0.1.1) 55 | json (~> 2.3) 56 | language_server-protocol (>= 3.17.0) 57 | parallel (~> 1.10) 58 | parser (>= 3.2.2.3) 59 | rainbow (>= 2.2.2, < 4.0) 60 | regexp_parser (>= 1.8, < 3.0) 61 | rexml (>= 3.2.5, < 4.0) 62 | rubocop-ast (>= 1.28.1, < 2.0) 63 | ruby-progressbar (~> 1.7) 64 | unicode-display_width (>= 2.4.0, < 3.0) 65 | rubocop-ast (1.46.0) 66 | parser (>= 3.3.7.2) 67 | prism (~> 1.4) 68 | rubocop-performance (1.23.1) 69 | rubocop (>= 1.48.1, < 2.0) 70 | rubocop-ast (>= 1.31.1, < 2.0) 71 | ruby-progressbar (1.13.0) 72 | securerandom (0.4.1) 73 | sqlite3 (1.5.4-arm64-darwin) 74 | sqlite3 (1.5.4-x86_64-linux) 75 | standard (1.31.2) 76 | language_server-protocol (~> 3.17.0.2) 77 | lint_roller (~> 1.0) 78 | rubocop (~> 1.56.4) 79 | standard-custom (~> 1.0.0) 80 | standard-performance (~> 1.2) 81 | standard-custom (1.0.2) 82 | lint_roller (~> 1.0) 83 | rubocop (~> 1.50) 84 | standard-performance (1.6.0) 85 | lint_roller (~> 1.1) 86 | rubocop-performance (~> 1.23.0) 87 | timeout (0.4.3) 88 | tzinfo (2.0.6) 89 | concurrent-ruby (~> 1.0) 90 | unicode-display_width (2.6.0) 91 | zeitwerk (2.6.18) 92 | 93 | PLATFORMS 94 | arm64-darwin-21 95 | arm64-darwin-24 96 | x86_64-linux 97 | 98 | DEPENDENCIES 99 | minitest (~> 5.0) 100 | rake (~> 13.0) 101 | sqlite3 (~> 1.5.0) 102 | standard (~> 1.31.0) 103 | validation_errors! 104 | 105 | BUNDLED WITH 106 | 2.5.18 107 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | $LOAD_PATH.unshift File.expand_path("../lib", __dir__) 4 | require "validation_errors" 5 | 6 | require "minitest/autorun" 7 | 8 | class Book < ActiveRecord::Base 9 | end 10 | 11 | class User < ActiveRecord::Base 12 | validates :password, format: %r{\A[0-9]+\z} 13 | end 14 | 15 | class TrackedBook < ActiveRecord::Base 16 | track_validation_errors 17 | 18 | self.table_name = "books" 19 | 20 | validates :title, presence: true 21 | end 22 | 23 | class Home < ActiveRecord::Base 24 | has_many :soldiers, dependent: :destroy 25 | 26 | validates :address, presence: true 27 | end 28 | 29 | class Soldier < ActiveRecord::Base 30 | belongs_to :home, optional: true 31 | belongs_to :king 32 | 33 | validates :name, presence: true 34 | validates :rank, presence: true 35 | 36 | accepts_nested_attributes_for :home, allow_destroy: true 37 | end 38 | 39 | class King < ActiveRecord::Base 40 | has_many :soldiers 41 | belongs_to :home, optional: true 42 | 43 | validates :title, presence: true 44 | 45 | accepts_nested_attributes_for :soldiers, allow_destroy: true 46 | end 47 | 48 | ActiveRecord::Base.logger = Logger.new($stdout) 49 | # ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:") 50 | # connect to a file 51 | ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: "db/development.sqlite3") 52 | ActiveRecord::Migration.verbose = false 53 | 54 | ActiveRecord::Schema.define do 55 | create_table :books, if_not_exists: true do |t| 56 | t.string :title 57 | t.string :author 58 | t.timestamps null: false 59 | end 60 | 61 | create_table :users, if_not_exists: true do |t| 62 | t.string :username 63 | t.string :password 64 | t.timestamps null: false 65 | end 66 | 67 | create_table :soldiers, if_not_exists: true do |t| 68 | t.string :name 69 | t.string :rank 70 | t.references :king, foreign_key: true, null: false 71 | t.references :home, foreign_key: true, null: true 72 | t.timestamps null: false 73 | end 74 | 75 | create_table :homes, if_not_exists: true do |t| 76 | t.string :address 77 | 78 | t.timestamps null: false 79 | end 80 | 81 | create_table :kings, if_not_exists: true do |t| 82 | t.references :home, foreign_key: true, null: false 83 | t.string :title 84 | t.timestamps null: false 85 | end 86 | 87 | create_table :validation_errors, if_not_exists: true do |t| 88 | t.string :invalid_model_name 89 | t.bigint :invalid_model_id 90 | t.string :action 91 | t.json :details 92 | t.timestamps null: false 93 | end 94 | 95 | # execute <<-SQL 96 | # CREATE OR REPLACE VIEW flat_validation_errors AS 97 | # select validation_errors.invalid_model_name, 98 | # validation_errors.invalid_model_id, 99 | # validation_errors.action, 100 | # validation_errors.created_at, 101 | # json_data.key as error_column, 102 | # json_array_elements(json_data.value)->>'error' as error_type 103 | # from validation_errors, json_each(validation_errors.details) as json_data; 104 | # SQL 105 | end 106 | -------------------------------------------------------------------------------- /test/test_validation_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "ostruct" 4 | require "test_helper" 5 | 6 | class TestValidationError < Minitest::Test 7 | def teardown 8 | Book.delete_all 9 | ValidationError.delete_all 10 | end 11 | 12 | def test_that_it_can_track_model_errors 13 | invalid_book = Book.new(id: 2) 14 | invalid_book.errors.add(:base, :invalid) 15 | invalid_book.errors.add(:title, :blank) 16 | invalid_book.errors.add(:title, :invalid) 17 | ValidationError.track(invalid_book) 18 | assert_equal 1, ValidationError.count 19 | assert_equal "Book", ValidationError.first.invalid_model_name 20 | assert_equal 2, ValidationError.first.invalid_model_id 21 | assert_equal "create", ValidationError.first.action 22 | assert_equal({"base" => [{"error" => "invalid"}], 23 | "title" => [{"error" => "blank"}, {"error" => "invalid"}]}, ValidationError.first.details) 24 | # assert_equal 3, FlatValidationError.count 25 | end 26 | 27 | def test_that_it_can_track_model_errors_with_custom_action_name 28 | invalid_book = Book.new(id: 2) 29 | invalid_book.errors.add(:base, :invalid) 30 | ValidationError.track(invalid_book, action: "import") 31 | assert_equal 1, ValidationError.count 32 | assert_equal "Book", ValidationError.first.invalid_model_name 33 | assert_equal "import", ValidationError.first.action 34 | end 35 | 36 | def test_that_models_can_track_on_save 37 | invalid_book = TrackedBook.new 38 | invalid_book.save 39 | assert_equal 1, ValidationError.count 40 | assert_equal "TrackedBook", ValidationError.first.invalid_model_name 41 | assert_nil ValidationError.first.invalid_model_id 42 | assert_equal "create", ValidationError.first.action 43 | assert_equal({"title" => [{"error" => "blank"}]}, ValidationError.first.details) 44 | end 45 | 46 | def test_that_models_can_track_on_save_bang 47 | invalid_book = TrackedBook.new 48 | begin 49 | invalid_book.save! 50 | rescue 51 | ActiveRecord::RecordInvalid 52 | end 53 | assert_equal 1, ValidationError.count 54 | assert_equal "TrackedBook", ValidationError.first.invalid_model_name 55 | assert_nil ValidationError.first.invalid_model_id 56 | assert_equal "create", ValidationError.first.action 57 | assert_equal({"title" => [{"error" => "blank"}]}, ValidationError.first.details) 58 | end 59 | 60 | def test_that_models_can_track_on_create 61 | TrackedBook.create(title: "") 62 | assert_equal 1, ValidationError.count 63 | assert_equal "TrackedBook", ValidationError.first.invalid_model_name 64 | assert_nil ValidationError.first.invalid_model_id 65 | assert_equal "create", ValidationError.first.action 66 | assert_equal({"title" => [{"error" => "blank"}]}, ValidationError.first.details) 67 | end 68 | 69 | def test_that_models_can_track_on_create_bang 70 | begin 71 | TrackedBook.create!(title: "") 72 | rescue 73 | ActiveRecord::RecordInvalid 74 | end 75 | assert_equal 1, ValidationError.count 76 | assert_equal "TrackedBook", ValidationError.first.invalid_model_name 77 | assert_nil ValidationError.first.invalid_model_id 78 | assert_equal "create", ValidationError.first.action 79 | assert_equal({"title" => [{"error" => "blank"}]}, ValidationError.first.details) 80 | end 81 | 82 | def test_that_models_do_not_track_on_create_if_no_errors 83 | TrackedBook.create(title: "The Hobbit") 84 | assert_equal 0, ValidationError.count 85 | end 86 | 87 | def test_that_models_can_track_on_update 88 | book = TrackedBook.create(title: "The Hobbit") 89 | book.update(title: "") 90 | assert_equal 1, ValidationError.count 91 | assert_equal "TrackedBook", ValidationError.first.invalid_model_name 92 | assert_equal book.id, ValidationError.first.invalid_model_id 93 | assert_equal "update", ValidationError.first.action 94 | assert_equal({"title" => [{"error" => "blank"}]}, ValidationError.first.details) 95 | end 96 | 97 | def test_that_models_can_track_on_update_bang 98 | book = TrackedBook.create(title: "The Hobbit") 99 | begin 100 | book.update!(title: "") 101 | rescue 102 | ActiveRecord::RecordInvalid 103 | end 104 | assert_equal 1, ValidationError.count 105 | assert_equal "TrackedBook", ValidationError.first.invalid_model_name 106 | assert_equal book.id, ValidationError.first.invalid_model_id 107 | assert_equal "update", ValidationError.first.action 108 | assert_equal({"title" => [{"error" => "blank"}]}, ValidationError.first.details) 109 | end 110 | 111 | def test_that_models_do_not_track_on_update_if_no_errors 112 | book = TrackedBook.create(title: "The Hobbit") 113 | book.update(title: "Harry Potter") 114 | assert_equal 0, ValidationError.count 115 | end 116 | 117 | module RailsMock 118 | def self.application 119 | filter_parameters = [:password, :ssn, /password/] 120 | @application ||= OpenStruct.new(config: OpenStruct.new(filter_parameters: filter_parameters)) 121 | end 122 | end 123 | 124 | def test_that_it_does_not_track_password 125 | # Temporarily replace Rails with RailsMock 126 | Object.const_set(:Rails, RailsMock) 127 | 128 | invalid_user = User.new(username: "alex", password: "thisissecret") 129 | invalid_user.valid? 130 | ValidationError.track(invalid_user) 131 | assert_equal 1, ValidationError.count 132 | assert_equal "User", ValidationError.first.invalid_model_name 133 | assert_nil ValidationError.first.invalid_model_id 134 | assert_equal "create", ValidationError.first.action 135 | assert_equal({"password" => [{"error" => "invalid", "value" => "***"}]}, ValidationError.first.details) 136 | assert_equal("thisissecret", invalid_user.errors.details[:password][0][:value]) 137 | end 138 | 139 | def test_that_it_can_track_nested_models 140 | invalid_king = King.new(id: 1) 141 | invalid_king.soldiers.build(name: "Arthur", home: Home.new) 142 | invalid_king.valid? 143 | ValidationError.track(invalid_king) 144 | assert_equal 1, ValidationError.count 145 | assert_equal "King", ValidationError.first.invalid_model_name 146 | assert_equal 1, ValidationError.first.invalid_model_id 147 | assert_equal "create", ValidationError.first.action 148 | assert_equal({"title" => [{"error" => "blank"}], 149 | "soldiers.rank" => [{"error" => "blank"}], "soldiers.home.address" => [{"error" => "blank"}]}, ValidationError.first.details) 150 | # assert_equal 3, FlatValidationError.count 151 | end 152 | end 153 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ValidationErrors 😱 2 | 3 | _Because shit happens_ 4 | 5 | This gem helps you keep track of the ActiveRecord validation errors that have been triggered on a model. 6 | 7 | It persists them on a `validation_errors` database table with: 8 | 9 | * time of the error 10 | * model name 11 | * model id (if available) 12 | * action (create/update) 13 | * errors.details (hash) 14 | 15 | ## Why? 16 | 17 | Validation errors happen. In some applications it might be interesting to keep track of them. 18 | This gem has been extracted from various Ruby On Rails apps where I had this need. 19 | 20 | If you have a validation error, you want to keep track of it...but how? 21 | * You can use a logger, but then you have to parse the logs to get the information you need. i.e. is not structured. 22 | * You can use an error tracker, like Sentry, but this isn't really an error, is it? If you have many, it might pollute your error tracker. 23 | * You can use a database table! And that's exactly what this gem does. 24 | 25 | This gem will keep track of the errors, and give you all the freedom to query it and extract statistics and make analysis. 26 | By analysing these data from time to time, you might found out the following: 27 | * Your UI sucks 28 | * Your validations are too strict 29 | * Your validations are too loose 30 | * Your client-side validations are not working 31 | * Your client-side validations are too loose 32 | * This bullet point list has been generated by AI 33 | 34 | ## Installation 35 | 36 | Install the gem and add it to the application's Gemfile by executing: 37 | 38 | $ bundle add validation_errors 39 | 40 | If bundler is not being used to manage dependencies, install the gem by executing: 41 | 42 | $ gem install validation_errors 43 | 44 | ## Usage 45 | 46 | Run `bin/rails g validation_errors:install` to create the migration. 47 | Stop evil spring, if the generator is not found: `spring stop`. 48 | 49 | Run `bin/rails db:migrate` to create the tables. 50 | 51 | The migration will create a `validation_errors` table. Check the migration for details. 52 | If you use [Scenic](https://github.com/scenic-views/scenic) it will also generate some useful views. 53 | 54 | ### Manual 55 | 56 | You can now manually track errors by calling `ValidationErrors.track`: 57 | 58 | ```ruby 59 | ValidationErrors.track(your_invalid_model) 60 | ``` 61 | 62 | An example could be the following: 63 | 64 | ```ruby 65 | def create 66 | @user = User.new(user_params) 67 | if @user.save 68 | redirect_to @user 69 | else 70 | ValidationError.track(@user) 71 | render :new 72 | end 73 | end 74 | ``` 75 | 76 | Yes, this will execute an additional INSERT into your database. Keep it in mind. 77 | 78 | `ValidationError.track(@model)` will automatically detect if the action is `create` or `update`. 79 | You can also use a custom action name by calling `ValidationError.track(@model, action: 'custom_action')`. 80 | 81 | ### Automatic 82 | 83 | You can also track validation errors automatically by adding `track_validation_errors` in your model. 84 | 85 | ```ruby 86 | class User < ApplicationRecord 87 | track_validation_errors 88 | end 89 | ``` 90 | 91 | by doing so, validation errors are tracked automatically on each `save`, `save!`, `update`, or `update!` call on your model. 92 | 93 | ### Global 94 | 95 | You can of course enable it on all you models by specifying it in ApplicationRecord directly: 96 | 97 | ```ruby 98 | class ApplicationRecord < ActiveRecord::Base 99 | self.abstract_class = true 100 | track_validation_errors 101 | end 102 | ``` 103 | 104 | ### Validate nested attributes 105 | 106 | If you don't specify `accepts_nested_attributes_for` for nested attributes, only a generic `invalid` error will be tracked for associations. 107 | 108 | We currently don't support (PR welcome): 109 | * enable globally and disable on a specific model `skip_track_validation_errors` 110 | * enable only for specific actions `track_validation_errors only: [:create]` 111 | * disable only for specific actions `track_validation_errors except: [:create]` 112 | * enable only for bang or non-bang methods `track_validation_errors only_bang: true`, `track_validation_errors only_non_bang: true` 113 | 114 | 115 | ## Query your data 116 | 117 | Now that you have installed the gem and started tracking, let's take a look at how the data are persisted and how you can query them. 118 | We store the errors in exactly the same format returned by ActiveRecord `errors.details`. 119 | 120 | Given a book, that failed to save because of validation errors, you'll get the following: 121 | 122 | | id | invalid_model_name | invalid_model_id | action | details | 123 | |----|--------------------|------------------|--------|-------------------------------------------------------------------------------------------------------| 124 | | 1 | Book | 1 | create | `{ "base" => [{ "error" => "invalid" }], "title" => [{ "error" => "blank" }, {"error" => "invalid"}] }` | 125 | 126 | The following SQL (Postgres only!) can be used to obtain a flattened view. 127 | You can use it in your queries, or create a database view: 128 | 129 | ```sql 130 | select validation_errors.invalid_model_name, 131 | validation_errors.invalid_model_id, 132 | validation_errors.action, 133 | validation_errors.created_at, 134 | json_data.key as error_column, 135 | json_array_elements(json_data.value)->>'error' as error_type 136 | from validation_errors, json_each(validation_errors.details) as json_data 137 | ``` 138 | 139 | **The gem will already create this view for you, if you use [Scenic](https://github.com/scenic-views/scenic).** 140 | 141 | The result is the following: 142 | 143 | | invalid_model_name | invalid_model_id | action | error_column | error_type | 144 | |--------------------|------------------|--------|--------------|------------| 145 | | Book | 1 | create | base | invalid | 146 | | Book | 1 | create | title | blank | 147 | | Book | 1 | create | title | invalid | 148 | 149 | 150 | Let's now check some useful queries: 151 | 152 | ### Count the number of errors per day 153 | 154 | ```sql 155 | select count(*), date(created_at) 156 | from validation_errors 157 | group by date(created_at) 158 | order by date(created_at) desc; 159 | ``` 160 | 161 | Please use [groupdate](https://github.com/ankane/groupdate) for more reliable results when grouping by date. 162 | 163 | ```ruby 164 | ValidationError.group_by_day(:created_at).count 165 | ``` 166 | 167 | ### Count the number of errors per model and attribute 168 | 169 | ```sql 170 | select validation_errors.invalid_model_name, 171 | json_data.key as error_column, 172 | json_array_elements(json_data.value)->>'error' as error_type, 173 | count(*) 174 | from validation_errors, 175 | json_each(validation_errors.details) as json_data 176 | group by 1, 2, 3 177 | order by 4 desc 178 | ``` 179 | 180 | or, if you have the view above: 181 | 182 | ```sql 183 | select invalid_model_name, error_column, count(*) 184 | from flat_validation_errors 185 | group by 1, 2 186 | ``` 187 | 188 | ```ruby 189 | FlatValidationError.group(:invalid_model_name, :error_column).count 190 | ``` 191 | 192 | ## RailsAdmin integration 193 | 194 | We provide here some code samples to integrate the models in [RailsAdmin](https://github.com/sferik/rails_admin). 195 | 196 | This configuration will give you a basic configuration to work with the validation errors efficiently. 197 | ```ruby 198 | config.model "ValidationError" do 199 | list do 200 | include_fields :invalid_model_name, :invalid_model_id, :action, :created_at 201 | 202 | field :details, :string do 203 | visible false 204 | searchable true 205 | filterable true 206 | end 207 | end 208 | 209 | show do 210 | include_fields :invalid_model_name, :invalid_model_id, :action 211 | 212 | field(:created_at) 213 | field(:details) do 214 | formatted_value { "
#{JSON.pretty_generate(bindings[:object].details)}
".html_safe } 215 | end 216 | end 217 | end 218 | ``` 219 | 220 | ## Development 221 | 222 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. 223 | You can also run `bin/console` for an interactive prompt that will allow you to experiment. 224 | 225 | To install this gem onto your local machine, run `bundle exec rake install`. 226 | 227 | To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, 228 | which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org). 229 | 230 | ## Contributing 231 | 232 | Bug reports and pull requests are welcome on GitHub at https://github.com/coorasse/validation_errors. 233 | This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/coorasse/validation_errors/blob/master/CODE_OF_CONDUCT.md). 234 | 235 | Try to be a decent human being while interacting with other people. 236 | 237 | ## License 238 | 239 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 240 | --------------------------------------------------------------------------------