diff --git a/README.md b/README.md index 7a076c2c..78181a2c 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,8 @@ By default changes are stored in YAML format. If you're using PostgreSQL, then y If you're using something other than integer primary keys (e.g. UUID) for your User model, then you can use `rails generate audited:install --audited-user-id-column-type uuid` to customize the `audits` table `user_id` column type. +If you would like to use a custom audits table, you can use `rails generate audited:install --audited-table-name custom_audits` to specify a custom audit table name, it will generate a schema migration file and a custom audit model in `app/models`. + #### Upgrading If you're already using Audited (or acts_as_audited), your `audits` table may require additional columns. After every upgrade, please run: @@ -416,7 +418,9 @@ class CustomAudit < Audited::Audit end end ``` + Then set it in an initializer: + ```ruby # config/initializers/audited.rb @@ -425,6 +429,31 @@ Audited.config do |config| end ``` +You can also specify a custom audit class on a per-model basis, which will override default audit class for the exact model. + +```ruby +class User < ActiveRecord::Base + audited as: "CustomAudit" +end + +# or with a custom class +class User < ActiveRecord::Base + audited as: CustomAudit +end +``` + +You can also supply a custom table name for the audit records. + +```ruby +class CustomAudit < Audited::Audit + self.table_name = "custom_audits" +end + +class User < ActiveRecord::Base + audited as: CustomAudit +end +``` + ### Enum Storage In 4.10, the default behavior for enums changed from storing the value synthesized by Rails to the value stored in the DB. You can restore the previous behavior by setting the store_synthesized_enums configuration value: diff --git a/lib/audited/audit.rb b/lib/audited/audit.rb index 54a51f18..4d7d9082 100644 --- a/lib/audited/audit.rb +++ b/lib/audited/audit.rb @@ -16,27 +16,29 @@ module Audited # class YAMLIfTextColumnType - class << self - def load(obj) - if text_column? - ActiveRecord::Coders::YAMLColumn.new(Object).load(obj) - else - obj - end - end + def initialize(audit_class) + @audit_class = audit_class + end - def dump(obj) - if text_column? - ActiveRecord::Coders::YAMLColumn.new(Object).dump(obj) - else - obj - end + def load(obj) + if text_column? + ActiveRecord::Coders::YAMLColumn.new(Object).load(obj) + else + obj end + end - def text_column? - Audited.audit_class.columns_hash["audited_changes"].type.to_s == "text" + def dump(obj) + if text_column? + ActiveRecord::Coders::YAMLColumn.new(Object).dump(obj) + else + obj end end + + def text_column? + @audit_class.columns_hash["audited_changes"].type.to_s == "text" + end end class Audit < ::ActiveRecord::Base @@ -46,13 +48,21 @@ class Audit < ::ActiveRecord::Base before_create :set_version_number, :set_audit_user, :set_request_uuid, :set_remote_address - cattr_accessor :audited_class_names - self.audited_class_names = Set.new + def self.add_audited_class(audited_class) + @@audited_classes ||= {} + @@audited_classes[name] ||= Set.new + @@audited_classes[name] << audited_class + end + + def self.audited_classes + @@audited_classes ||= {} + @@audited_classes[name] ||= Set.new + end if Rails.gem_version >= Gem::Version.new("7.1") - serialize :audited_changes, coder: YAMLIfTextColumnType + serialize :audited_changes, coder: YAMLIfTextColumnType.new(self) else - serialize :audited_changes, YAMLIfTextColumnType + serialize :audited_changes, YAMLIfTextColumnType.new(self) end scope :ascending, -> { reorder(version: :asc) } @@ -129,11 +139,6 @@ def user_as_string alias_method :user_as_model, :user alias_method :user, :user_as_string - # Returns the list of classes that are being audited - def self.audited_classes - audited_class_names.map(&:constantize) - end - # All audits made during the block called will be recorded as made # by +user+. This method is hopefully threadsafe, making it ideal # for background operations that require audit information. diff --git a/lib/audited/auditor.rb b/lib/audited/auditor.rb index a164f72a..7b3b69d0 100644 --- a/lib/audited/auditor.rb +++ b/lib/audited/auditor.rb @@ -74,17 +74,31 @@ def set_audit(options) class_attribute :audit_associated_with, instance_writer: false class_attribute :audited_options, instance_writer: false + class_attribute :audit_class, instance_writer: false + attr_accessor :audit_version, :audit_comment set_audited_options(options) + self.audit_class = case audited_options[:as] + when String, Symbol + audited_options[:as].to_s.safe_constantize + when Class + audited_options[:as] + else + Audited.audit_class + end + if audit_class.nil? + raise "No audit class resolved. Please specify existing audit class using the `:as` option or remove it." + end + if audited_options[:comment_required] validate :presence_of_audit_comment before_destroy :require_comment if audited_options[:on].include?(:destroy) end - has_many :audits, -> { order(version: :asc) }, as: :auditable, class_name: Audited.audit_class.name, inverse_of: :auditable - Audited.audit_class.audited_class_names << to_s + has_many :audits, -> { order(version: :asc) }, as: :auditable, class_name: audit_class.name, inverse_of: :auditable + audit_class.add_audited_class(self) after_create :audit_create if audited_options[:on].include?(:create) before_update :audit_update if audited_options[:on].include?(:update) @@ -102,7 +116,7 @@ def set_audit(options) end def has_associated_audits - has_many :associated_audits, as: :associated, class_name: Audited.audit_class.name + has_many :associated_audits, as: :associated, class_name: audit_class.name end def update_audited_options(new_options) @@ -176,14 +190,14 @@ def revisions(from_version = 1) # Returns nil for versions greater than revisions count def revision(version) if version == :previous || audits.last.version >= version - revision_with Audited.audit_class.reconstruct_attributes(audits_to(version)) + revision_with audit_class.reconstruct_attributes(audits_to(version)) end end # Find the oldest revision recorded prior to the date/time provided. def revision_at(date_or_time) audits = self.audits.up_until(date_or_time) - revision_with Audited.audit_class.reconstruct_attributes(audits) unless audits.empty? + revision_with audit_class.reconstruct_attributes(audits) unless audits.empty? end # List of attributes that are audited. @@ -196,8 +210,8 @@ def audited_attributes # Returns a list combined of record audits and associated audits. def own_and_associated_audits - Audited.audit_class.unscoped.where(auditable: self) - .or(Audited.audit_class.unscoped.where(associated: self)) + audit_class.unscoped.where(auditable: self) + .or(audit_class.unscoped.where(associated: self)) .order(created_at: :desc) end @@ -229,7 +243,7 @@ def revision_with(attributes) revision.send :instance_variable_set, "@destroyed", false revision.send :instance_variable_set, "@_destroyed", false revision.send :instance_variable_set, "@marked_for_destruction", false - Audited.audit_class.assign_revision_attributes(revision, attributes) + audit_class.assign_revision_attributes(revision, attributes) # Remove any association proxies so that they will be recreated # and reference the correct object for this revision. The only way @@ -508,7 +522,7 @@ def enable_auditing # convenience wrapper around # @see Audit#as_user. def audit_as(user, &block) - Audited.audit_class.as_user(user, &block) + audit_class.as_user(user, &block) end def auditing_enabled diff --git a/lib/audited/rspec_matchers.rb b/lib/audited/rspec_matchers.rb index 1999068c..675b3f98 100644 --- a/lib/audited/rspec_matchers.rb +++ b/lib/audited/rspec_matchers.rb @@ -221,7 +221,7 @@ def reflection def association_exists? !reflection.nil? && reflection.macro == :has_many && - reflection.options[:class_name] == Audited.audit_class.name + reflection.options[:class_name] == model_class.audit_class.name end end end diff --git a/lib/generators/audited/install_generator.rb b/lib/generators/audited/install_generator.rb index 8bf64182..84dea4a9 100644 --- a/lib/generators/audited/install_generator.rb +++ b/lib/generators/audited/install_generator.rb @@ -16,11 +16,22 @@ class InstallGenerator < Rails::Generators::Base class_option :audited_changes_column_type, type: :string, default: "text", required: false class_option :audited_user_id_column_type, type: :string, default: "integer", required: false + class_option :audited_table_name, type: :string, default: "audits", required: false source_root File.expand_path("../templates", __FILE__) def copy_migration - migration_template "install.rb", "db/migrate/install_audited.rb" + name = "db/migrate/install_audited.rb" + if options[:audited_table_name] != "audits" + name = "db/migrate/create_#{options[:audited_table_name].underscore.pluralize}.rb" + end + migration_template "install.rb", name + end + + def create_custom_audit + return if options[:audited_table_name] == "audits" + + template "custom_audit.rb", "app/models/#{options[:audited_table_name].singularize.underscore}.rb" end end end diff --git a/lib/generators/audited/templates/custom_audit.rb b/lib/generators/audited/templates/custom_audit.rb new file mode 100644 index 00000000..9d8dcbac --- /dev/null +++ b/lib/generators/audited/templates/custom_audit.rb @@ -0,0 +1,4 @@ +<%- table_name = options[:audited_table_name].underscore.pluralize -%> +class <%= table_name.singularize.classify %> < Audited::Audit + self.table_name = "<%= table_name %>" +end diff --git a/lib/generators/audited/templates/install.rb b/lib/generators/audited/templates/install.rb index 5c6807f9..201c104e 100644 --- a/lib/generators/audited/templates/install.rb +++ b/lib/generators/audited/templates/install.rb @@ -1,8 +1,7 @@ -# frozen_string_literal: true - +<%- table_name = options[:audited_table_name].underscore.pluralize -%> class <%= migration_class_name %> < <%= migration_parent %> def self.up - create_table :audits, :force => true do |t| + create_table :<%= table_name %> do |t| t.column :auditable_id, :integer t.column :auditable_type, :string t.column :associated_id, :integer @@ -12,21 +11,21 @@ def self.up t.column :username, :string t.column :action, :string t.column :audited_changes, :<%= options[:audited_changes_column_type] %> - t.column :version, :integer, :default => 0 + t.column :version, :integer, default: 0 t.column :comment, :string t.column :remote_address, :string t.column :request_uuid, :string t.column :created_at, :datetime end - add_index :audits, [:auditable_type, :auditable_id, :version], :name => 'auditable_index' - add_index :audits, [:associated_type, :associated_id], :name => 'associated_index' - add_index :audits, [:user_id, :user_type], :name => 'user_index' - add_index :audits, :request_uuid - add_index :audits, :created_at + add_index :<%= table_name %>, [:auditable_type, :auditable_id, :version], name: "<%= table_name %>_auditable_index" + add_index :<%= table_name %>, [:associated_type, :associated_id], name: "<%= table_name %>_associated_index" + add_index :<%= table_name %>, [:user_id, :user_type], name: "<%= table_name %>_user_index" + add_index :<%= table_name %>, :request_uuid + add_index :<%= table_name %>, :created_at end def self.down - drop_table :audits + drop_table :<%= table_name %> end end diff --git a/lib/generators/audited/upgrade_generator.rb b/lib/generators/audited/upgrade_generator.rb index b66d082d..23c4f633 100644 --- a/lib/generators/audited/upgrade_generator.rb +++ b/lib/generators/audited/upgrade_generator.rb @@ -14,6 +14,8 @@ class UpgradeGenerator < Rails::Generators::Base include Audited::Generators::MigrationHelper extend Audited::Generators::Migration + class_option :audited_table_name, type: :string, default: "audits", required: false + source_root File.expand_path("../templates", __FILE__) def copy_templates diff --git a/spec/audited/audit_spec.rb b/spec/audited/audit_spec.rb index c18abdb5..11b18c25 100644 --- a/spec/audited/audit_spec.rb +++ b/spec/audited/audit_spec.rb @@ -70,7 +70,7 @@ class Models::ActiveRecord::CustomUserSubclass < Models::ActiveRecord::CustomUse end it "does not unserialize from binary columns" do - allow(Audited::YAMLIfTextColumnType).to receive(:text_column?).and_return(false) + allow_any_instance_of(Audited::YAMLIfTextColumnType).to receive(:text_column?).and_return(false) audit.audited_changes = {foo: "bar"} expect(audit.audited_changes).to eq "{:foo=>\"bar\"}" end diff --git a/spec/audited/auditor_spec.rb b/spec/audited/auditor_spec.rb index 15ff2f77..3dd32f9a 100644 --- a/spec/audited/auditor_spec.rb +++ b/spec/audited/auditor_spec.rb @@ -67,6 +67,15 @@ class Secret2 < ::ActiveRecord::Base self.non_audited_columns = ["delta", "top_secret", "created_at"] end +class CustomAudit < Audited::Audit + self.table_name = "custom_audits" +end + +class CustomCompany < ::ActiveRecord::Base + self.table_name = "companies" + audited as: CustomAudit +end + describe Audited::Auditor do describe "configuration" do it "should include instance methods" do @@ -267,6 +276,95 @@ class CallbacksSpecified < ::ActiveRecord::Base end end + context "when using default audit class" do + it "should use default audit class" do + expect(Models::ActiveRecord::Company.audit_class).to eq(Audited::Audit) + end + + context "when using custom audit class" do + before do + eval %(class TemporaryCustomAudit < Audited::Audit; end), binding, __FILE__, __LINE__ + Audited.config do |config| + config.audit_class = TemporaryCustomAudit + end + eval %(class TemporaryCustomSecret < ::ActiveRecord::Base; audited as: CustomAudit; end), binding, __FILE__, __LINE__ + eval %(class TemporarySecret < ::ActiveRecord::Base; audited; end), binding, __FILE__, __LINE__ + end + + after do + Audited.config do |config| + config.audit_class = Audited::Audit + end + Object.send(:remove_const, :TemporarySecret) + Object.send(:remove_const, :TemporaryCustomSecret) + Object.send(:remove_const, :TemporaryCustomAudit) + end + + it "should use custom default audit class" do + expect(TemporarySecret.audit_class).to eq(TemporaryCustomAudit) + end + + it "should not affect models with custom audit class" do + expect(TemporaryCustomSecret.audit_class).to eq(CustomAudit) + end + end + + context "when using wrong audit class" do + it "should raise error" do + expect { + eval %(class TemporaryWrongAudited < ::ActiveRecord::Base; audited as: "WrongAudit"; end), binding, __FILE__, __LINE__ + }.to raise_error(StandardError, "No audit class resolved. Please specify existing audit class using the `:as` option or remove it.") + end + end + end + + context "when using custom audit class" do + it "should use custom audit class" do + expect(CustomCompany.audit_class).to eq(CustomAudit) + end + + it "should have correct table name" do + expect(CustomAudit.table_name).to eq("custom_audits") + expect(CustomAudit.table_name).not_to eq(Audited::Audit.table_name) + end + + it "should have association with custom audit class" do + expect(CustomAudit.audited_classes).to include(CustomCompany) + end + + it "should not have association with default audit class" do + expect(Audited::Audit.audited_classes).not_to include(CustomCompany) + end + end + + context "when using custom audit class as string" do + before do + eval %(class TemporaryCustomCompany < ::ActiveRecord::Base; self.table_name = "companies"; audited as: "CustomAudit"; end), binding, __FILE__, __LINE__ + end + + after do + Object.send(:remove_const, :TemporaryCustomCompany) + end + + it "should resolve custom audit class" do + expect(TemporaryCustomCompany.audit_class).to eq(CustomAudit) + end + end + + context "when using custom audit class as symbol" do + before do + eval %(class TemporaryCustomCompany < ::ActiveRecord::Base; self.table_name = "companies"; audited as: :CustomAudit; end), binding, __FILE__, __LINE__ + end + + after do + Object.send(:remove_const, :TemporaryCustomCompany) + end + + it "should resolve custom audit class" do + expect(TemporaryCustomCompany.audit_class).to eq(CustomAudit) + end + end + if ::ActiveRecord::VERSION::MAJOR >= 7 it "should filter encrypted attributes" do user = Models::ActiveRecord::UserWithEncryptedPassword.create(password: "password") @@ -386,6 +484,29 @@ class CallbacksSpecified < ::ActiveRecord::Base Models::ActiveRecord::UserWithReadOnlyAttrs.create!(name: "Bart") }.to change(Audited::Audit, :count) end + + context "when using custom audit class" do + let(:company) { CustomCompany.create!(name: "Custom Company") } + + it "should have correct configuration" do + expect(CustomCompany.audit_class).to eq(CustomAudit) + expect(company.audit_class).to eq(CustomAudit) + end + + it "should create custom audit" do + expect(company.audits.first.class).to eq(CustomAudit) + expect(company.audits.first.action).to eq("create") + expect(CustomAudit.creates.order(:id).last).to eq(company.audits.first) + expect(company.audits.creates.count).to eq(1) + expect(company.audits.updates.count).to eq(0) + expect(company.audits.destroys.count).to eq(0) + end + + it "should not create default audit record" do + expect { company }.not_to change(Audited::Audit, :count) + expect(Audited::Audit.where(auditable_type: CustomCompany.name).count).to eq(0) + end + end end describe "on update" do diff --git a/spec/support/active_record/models.rb b/spec/support/active_record/models.rb index 53b57863..a4e38782 100644 --- a/spec/support/active_record/models.rb +++ b/spec/support/active_record/models.rb @@ -138,6 +138,14 @@ class Company < ::ActiveRecord::Base audited end + class CustomAudit < Audited::Audit + end + + class CustomCompany < ::ActiveRecord::Base + self.table_name = "companies" + audited as: CustomAudit + end + class Company::STICompany < Company end diff --git a/spec/support/active_record/schema.rb b/spec/support/active_record/schema.rb index 7145bc0c..bf0035c9 100644 --- a/spec/support/active_record/schema.rb +++ b/spec/support/active_record/schema.rb @@ -65,26 +65,31 @@ t.column :title, :string end - create_table :audits do |t| - t.column :auditable_id, :integer - t.column :auditable_type, :string - t.column :associated_id, :integer - t.column :associated_type, :string - t.column :user_id, :integer - t.column :user_type, :string - t.column :username, :string - t.column :action, :string - t.column :audited_changes, :text - t.column :version, :integer, default: 0 - t.column :comment, :string - t.column :remote_address, :string - t.column :request_uuid, :string - t.column :created_at, :datetime + def create_audits_table(table_name) + create_table table_name do |t| + t.column :auditable_id, :integer + t.column :auditable_type, :string + t.column :associated_id, :integer + t.column :associated_type, :string + t.column :user_id, :integer + t.column :user_type, :string + t.column :username, :string + t.column :action, :string + t.column :audited_changes, :text + t.column :version, :integer, default: 0 + t.column :comment, :string + t.column :remote_address, :string + t.column :request_uuid, :string + t.column :created_at, :datetime + end + + add_index table_name, [:auditable_id, :auditable_type] + add_index table_name, [:associated_id, :associated_type] + add_index table_name, [:user_id, :user_type] + add_index table_name, :request_uuid + add_index table_name, :created_at end - add_index :audits, [:auditable_id, :auditable_type], name: "auditable_index" - add_index :audits, [:associated_id, :associated_type], name: "associated_index" - add_index :audits, [:user_id, :user_type], name: "user_index" - add_index :audits, :request_uuid - add_index :audits, :created_at + create_audits_table :audits + create_audits_table :custom_audits end diff --git a/test/install_generator_test.rb b/test/install_generator_test.rb index 5c1c36d0..6352e6c4 100644 --- a/test/install_generator_test.rb +++ b/test/install_generator_test.rb @@ -59,4 +59,16 @@ class InstallGeneratorTest < Rails::Generators::TestCase assert_includes(content, "class InstallAudited < ActiveRecord::Migration[#{ActiveRecord::Migration.current_version}]\n") end end + + test "generate custom audit" do + run_generator %w[--audited-table-name custom_audits] + + assert_migration "db/migrate/create_custom_audits.rb" do |content| + assert_includes(content, "class CreateCustomAudits < ActiveRecord::Migration") + end + + assert_file "app/models/custom_audit.rb" do |content| + assert_includes(content, "class CustomAudit < Audited::Audit") + end + end end