Skip to content

Add support for audit_attributes #732

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,63 @@ class User < ActiveRecord::Base
end
```

### Custom Audit Attributes

The `audit_attributes` feature allows you to dynamically set custom attributes when creating audit records. This is useful if you have added additional columns to your audit table and want to include specific values during the auditing process.

For example, if you want to add a custom_attribute column to the audits table, create a migration:

```ruby
class AddCustomAttributeToAudits < ActiveRecord::Migration[7.0]
def change
add_column :audits, :custom_attribute, :string
end
end
```

Run the migration:

```bash
$ rails db:migrate
```

To use `audit_attributes`, pass a hash containing the custom attributes you want to set when creating or updating a record:

```ruby
class User < ActiveRecord::Base
audited
end

user = User.create!(
name: "John Doe",
audit_attributes: { custom_attribute: "Extra Info" }
)

audit = user.audits.last
audit.custom_attribute # => "Extra Info"
```

The keys provided in `audit_attributes` must correspond to existing columns in your custom `audit` table. If an invalid key is included, an error will be raised:

```ruby
user = User.create!(
name: "Steve",
audit_attributes: { invalid_key: "Invalid" }
)
# => Raises ActiveRecord::RecordInvalid
```

If a key in `audit_attributes` matches a predefined attribute, it will override the default value set during the auditing process:

```ruby
user.update!(
name: "Jane Doe",
audit_attributes: { comment: "Overridden comment" }
)

user.audits.last.comment # => "Overridden comment"
```

### Limiting stored audits

You can limit the number of audits stored for your model. To configure limiting for all audited models, put the following in an initializer file (`config/initializers/audited.rb`):
Expand Down
25 changes: 20 additions & 5 deletions lib/audited/auditor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ def set_audit(options)

class_attribute :audit_associated_with, instance_writer: false
class_attribute :audited_options, instance_writer: false
attr_accessor :audit_version, :audit_comment
attr_accessor :audit_version, :audit_comment, :audit_attributes

set_audited_options(options)

Expand All @@ -83,6 +83,8 @@ def set_audit(options)
before_destroy :require_comment if audited_options[:on].include?(:destroy)
end

validate :validate_audit_attributes

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

Expand Down Expand Up @@ -348,32 +350,33 @@ def audits_to(version = nil)

def audit_create
write_audit(action: "create", audited_changes: audited_attributes,
comment: audit_comment)
comment: audit_comment, **safe_audit_attributes)
end

def audit_update
unless (changes = audited_changes(exclude_readonly_attrs: true)).empty? && (audit_comment.blank? || audited_options[:update_with_comment_only] == false)
write_audit(action: "update", audited_changes: changes,
comment: audit_comment)
comment: audit_comment, **safe_audit_attributes)
end
end

def audit_touch
unless (changes = audited_changes(for_touch: true, exclude_readonly_attrs: true)).empty?
write_audit(action: "update", audited_changes: changes,
comment: audit_comment)
comment: audit_comment, **safe_audit_attributes)
end
end

def audit_destroy
unless new_record?
write_audit(action: "destroy", audited_changes: audited_attributes,
comment: audit_comment)
comment: audit_comment, **safe_audit_attributes)
end
end

def write_audit(attrs)
self.audit_comment = nil
self.audit_attributes = nil

if auditing_enabled
attrs[:associated] = send(audit_associated_with) unless audit_associated_with.nil?
Expand All @@ -392,6 +395,18 @@ def presence_of_audit_comment
end
end

def validate_audit_attributes
return unless audit_attributes.present?
audit_columns = Audited.audit_class.column_names.map(&:to_sym)
if !audit_attributes.is_a?(Hash) || (audit_attributes.keys - audit_columns).any?
errors.add(:audit_attributes, "must be a hash including only the keys of the audit class (#{audit_columns.join(", ")})")
end
end

def safe_audit_attributes
audit_attributes.is_a?(Hash) ? audit_attributes : {}
end

def comment_required_state?
auditing_enabled &&
audited_changes.present? &&
Expand Down
48 changes: 45 additions & 3 deletions spec/audited/auditor_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -329,7 +329,7 @@ class CallbacksSpecified < ::ActiveRecord::Base
end

describe "on create" do
let(:user) { create_user status: :reliable, audit_comment: "Create" }
let(:user) { create_user status: :reliable, audit_comment: "Create", audit_attributes: {custom_attribute: "Create Value"} }

it "should change the audit count" do
expect {
Expand Down Expand Up @@ -370,6 +370,10 @@ class CallbacksSpecified < ::ActiveRecord::Base
expect(user.audits.first.comment).to eq("Create")
end

it "should set the audit_attributes" do
expect(user.audits.first.custom_attribute).to eq("Create Value")
end

it "should not audit an attribute which is excepted if specified on create or destroy" do
on_create_destroy_except_name = Models::ActiveRecord::OnCreateDestroyExceptName.create(name: "Bart")
expect(on_create_destroy_except_name.audits.first.audited_changes.keys.any? { |col| ["name"].include? col }).to eq(false)
Expand All @@ -390,7 +394,7 @@ class CallbacksSpecified < ::ActiveRecord::Base

describe "on update" do
before do
@user = create_user(name: "Brandon", status: :active, audit_comment: "Update")
@user = create_user(name: "Brandon", status: :active, audit_comment: "Update", audit_attributes: {custom_attribute: "Update Value"})
end

it "should save an audit" do
Expand Down Expand Up @@ -423,6 +427,10 @@ class CallbacksSpecified < ::ActiveRecord::Base
expect(@user.audits.last.comment).to eq("Update")
end

it "should set the audit_attributes" do
expect(@user.audits.first.custom_attribute).to eq("Update Value")
end

it "should not save an audit if only specified on create/destroy" do
on_create_destroy = Models::ActiveRecord::OnCreateDestroy.create(name: "Bart")
expect {
Expand Down Expand Up @@ -1135,7 +1143,7 @@ def stub_global_max_audits(max_audits)
end

describe "on update" do
let(:user) { Models::ActiveRecord::CommentRequiredUser.create!(audit_comment: "Create") }
let(:user) { Models::ActiveRecord::CommentRequiredUser.create!(audit_comment: "Create", audit_attributes: {custom_attribute: "Create Value"}) }
let(:on_create_user) { Models::ActiveRecord::OnDestroyCommentRequiredUser.create }
let(:on_destroy_user) { Models::ActiveRecord::OnDestroyCommentRequiredUser.create }

Expand Down Expand Up @@ -1271,6 +1279,40 @@ def stub_global_max_audits(max_audits)
end
end

describe "Setting audit_attributes" do
let(:user) { create_user status: :reliable, audit_comment: "Create", audit_attributes: {custom_attribute: "Create Value", comment: "Overrided Comment"} }

it "should change the audit count" do
expect {
user
}.to change(Models::ActiveRecord::User, :count).by(1)
end

it "should set custom attributes" do
expect(user.audits.first.custom_attribute).to eq("Create Value")
end

it "should override set attributes (comment)" do
expect(user.audits.first.comment).to eq("Overrided Comment")
end

it "doesn't affect unset attributes (status)" do
expect(user.audits.first.audited_changes["status"]).to eq(1)
end

it "validates that audit_attributes is a hash" do
expect {
Models::ActiveRecord::User.create!(audit_attributes: 4)
}.to raise_error(ActiveRecord::RecordInvalid).with_message(/Audit attributes must be a hash including only the keys of the audit class/)
end

it "validates that audit_attributes is a hash with only valid keys" do
expect {
Models::ActiveRecord::User.create!(audit_attributes: {custom_attribute: "Create Value", comment: "Overrided Comment", invalid_key: "Invalid Key"})
}.to raise_error(ActiveRecord::RecordInvalid).with_message(/Audit attributes must be a hash including only the keys of the audit class/)
end
end

describe "call audit multiple times" do
it "should update audit options" do
user = Models::ActiveRecord::UserOnlyName.create
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class AddColumnCustomAttributeToAudits < ActiveRecord::Migration[5.0]
def change
add_column :audits, :custom_attribute, :string
end
end
1 change: 1 addition & 0 deletions spec/support/active_record/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@
t.column :remote_address, :string
t.column :request_uuid, :string
t.column :created_at, :datetime
t.column :custom_attribute, :string
end

add_index :audits, [:auditable_id, :auditable_type], name: "auditable_index"
Expand Down