From 6afeb9a4638025e34b5f99a4e83758e84a4021b6 Mon Sep 17 00:00:00 2001 From: Andrei Makarov Date: Wed, 30 Oct 2024 13:31:21 +0200 Subject: [PATCH 1/3] Implement `.audit_sql` method for fetching next audit insert query --- README.md | 16 ++++++++++++++++ lib/audited/auditor.rb | 36 ++++++++++++++++++++++++++++++++++++ spec/audited/auditor_spec.rb | 22 ++++++++++++++++++++++ 3 files changed, 74 insertions(+) diff --git a/README.md b/README.md index 7a076c2c..1a03c203 100644 --- a/README.md +++ b/README.md @@ -435,6 +435,22 @@ In 4.10, the default behavior for enums changed from storing the value synthesiz Audited.store_synthesized_enums = true ``` +### Audit SQL + +To fetch the SQL used to create the audit, you can use the `audit_sql` method. Can be useful for batched operations or for debugging. + +```ruby +class User < ActiveRecord::Base + audited +end + +user = User.new(name: "Brandon") +user.disable_auditing +user.audit_sql +``` + +NOTE: This method will return non-nil value only if the record is dirty (is new or has changes). + ## Support You can find documentation at: https://www.rubydoc.info/gems/audited diff --git a/lib/audited/auditor.rb b/lib/audited/auditor.rb index c9c867ae..ec404d19 100644 --- a/lib/audited/auditor.rb +++ b/lib/audited/auditor.rb @@ -202,6 +202,42 @@ def combine_audits(audits_to_combine) end end + def audit_sql(destroy: false) + return unless changed? + + action = if new_record? + "create" + elsif destroy + "destroy" + else + "update" + end + attrs = { + action: action, + audited_changes: audited_changes, + comment: audit_comment + } + attrs[:associated] = send(audit_associated_with) unless audit_associated_with.nil? + changes = run_callbacks(:audit) do + audit = audits.new(attrs) + audit.run_callbacks(:create) + audit.changes + end + return if changes.empty? + + updates = changes.each_with_object({}) { |(k, v), h| h[k] = v.last } + stmt = Arel::InsertManager.new + table = Arel::Table.new(Audited.audit_class.table_name) + column_names = Audited.audit_class.column_names + stmt.into(table) + updates["audited_changes"] = updates["audited_changes"].to_json + updates["created_at"] ||= Time.current if column_names.include?("created_at") + updates["updated_at"] ||= Time.current if column_names.include?("updated_at") + updates.keys.each { |key| stmt.columns << table[key] } + stmt.values = stmt.create_values(updates.values) + stmt.to_sql + end + protected def revision_with(attributes) diff --git a/spec/audited/auditor_spec.rb b/spec/audited/auditor_spec.rb index cff4044b..489baf32 100644 --- a/spec/audited/auditor_spec.rb +++ b/spec/audited/auditor_spec.rb @@ -437,6 +437,28 @@ class CallbacksSpecified < ::ActiveRecord::Base expect { @user.update_attribute :activated, "1" }.to_not change(Audited::Audit, :count) end + it "should return sql for audit when changes are made" do + expect(@user.new_record?).to eq(false) + expect(@user.changes).to be_blank + expect(@user.audit_sql).to eq(nil) + + @user.assign_attributes(name: "Changed") + audit_sql = @user.audit_sql + + matches = audit_sql.match(/INSERT INTO "audits" \((.*?)\) VALUES \((.*?)\)/) + columns = matches[1].split(", ").map { |c| c.delete('"') } + values = matches[2].split(", ").map { |v| v.delete("'") } + parsed_sql = columns.zip(values).to_h + expect(parsed_sql["auditable_id"]).to eq("1") + expect(parsed_sql["auditable_type"]).to eq("Models::ActiveRecord::User") + expect(parsed_sql["action"]).to eq("update") + expect(parsed_sql["audited_changes"]).to include('"name":["Brandon","Changed"]') + expect(parsed_sql["version"]).to eq("2") + + @user.save! + expect(@user.audit_sql).to eq(nil) + end + context "with readonly attributes" do before do @user = create_user_with_readonly_attrs(status: "active") From 0618fc0e8a4eb05efffb4efd29233115a2366b19 Mon Sep 17 00:00:00 2001 From: Andrei Makarov Date: Wed, 30 Oct 2024 13:36:51 +0200 Subject: [PATCH 2/3] Fix temporary record leaking to audits association, remove it after fetching changes --- lib/audited/auditor.rb | 4 +++- spec/audited/auditor_spec.rb | 9 +++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/lib/audited/auditor.rb b/lib/audited/auditor.rb index ec404d19..83ff47cd 100644 --- a/lib/audited/auditor.rb +++ b/lib/audited/auditor.rb @@ -221,7 +221,9 @@ def audit_sql(destroy: false) changes = run_callbacks(:audit) do audit = audits.new(attrs) audit.run_callbacks(:create) - audit.changes + result = audit.changes + audits.delete(audit) + result end return if changes.empty? diff --git a/spec/audited/auditor_spec.rb b/spec/audited/auditor_spec.rb index 489baf32..dcee61c5 100644 --- a/spec/audited/auditor_spec.rb +++ b/spec/audited/auditor_spec.rb @@ -449,14 +449,19 @@ class CallbacksSpecified < ::ActiveRecord::Base columns = matches[1].split(", ").map { |c| c.delete('"') } values = matches[2].split(", ").map { |v| v.delete("'") } parsed_sql = columns.zip(values).to_h - expect(parsed_sql["auditable_id"]).to eq("1") + # expect(parsed_sql["auditable_id"]).to eq("1") expect(parsed_sql["auditable_type"]).to eq("Models::ActiveRecord::User") expect(parsed_sql["action"]).to eq("update") - expect(parsed_sql["audited_changes"]).to include('"name":["Brandon","Changed"]') + expect(parsed_sql["audited_changes"]).to eq('{"name":["Brandon","Changed"]}') expect(parsed_sql["version"]).to eq("2") @user.save! expect(@user.audit_sql).to eq(nil) + + last_audit = @user.audits.last + expect(last_audit.action).to eq("update") + expect(last_audit.audited_changes).to eq({"name" => ["Brandon", "Changed"]}) + expect(last_audit.version).to eq(2) end context "with readonly attributes" do From 030f54f9a1643ad89b1c12d4c3d9b8ea301cb0c6 Mon Sep 17 00:00:00 2001 From: Andrei Makarov Date: Wed, 30 Oct 2024 14:00:58 +0200 Subject: [PATCH 3/3] Cover more edge cases for using audit_sql --- lib/audited/auditor.rb | 18 +++++++++--------- spec/audited/auditor_spec.rb | 25 +++++++++++++++++++++---- 2 files changed, 30 insertions(+), 13 deletions(-) diff --git a/lib/audited/auditor.rb b/lib/audited/auditor.rb index 83ff47cd..e128a599 100644 --- a/lib/audited/auditor.rb +++ b/lib/audited/auditor.rb @@ -203,7 +203,7 @@ def combine_audits(audits_to_combine) end def audit_sql(destroy: false) - return unless changed? + return unless changed? || destroy action = if new_record? "create" @@ -212,6 +212,7 @@ def audit_sql(destroy: false) else "update" end + attrs = { action: action, audited_changes: audited_changes, @@ -225,18 +226,17 @@ def audit_sql(destroy: false) audits.delete(audit) result end - return if changes.empty? + changes = changes.each_with_object({}) do |(k, v), h| + h[k] = v.last + h[k] = h[k].to_json if h[k].is_a?(Hash) + end + changes["created_at"] ||= Time.current - updates = changes.each_with_object({}) { |(k, v), h| h[k] = v.last } stmt = Arel::InsertManager.new table = Arel::Table.new(Audited.audit_class.table_name) - column_names = Audited.audit_class.column_names stmt.into(table) - updates["audited_changes"] = updates["audited_changes"].to_json - updates["created_at"] ||= Time.current if column_names.include?("created_at") - updates["updated_at"] ||= Time.current if column_names.include?("updated_at") - updates.keys.each { |key| stmt.columns << table[key] } - stmt.values = stmt.create_values(updates.values) + changes.keys.each { |key| stmt.columns << table[key] } + stmt.values = stmt.create_values(changes.values) stmt.to_sql end diff --git a/spec/audited/auditor_spec.rb b/spec/audited/auditor_spec.rb index dcee61c5..55bf423f 100644 --- a/spec/audited/auditor_spec.rb +++ b/spec/audited/auditor_spec.rb @@ -445,10 +445,13 @@ class CallbacksSpecified < ::ActiveRecord::Base @user.assign_attributes(name: "Changed") audit_sql = @user.audit_sql - matches = audit_sql.match(/INSERT INTO "audits" \((.*?)\) VALUES \((.*?)\)/) - columns = matches[1].split(", ").map { |c| c.delete('"') } - values = matches[2].split(", ").map { |v| v.delete("'") } - parsed_sql = columns.zip(values).to_h + def parse_sql(sql) + matches = sql.match(/INSERT INTO "audits" \((.*?)\) VALUES \((.*?)\)/) + columns = matches[1].split(", ").map { |c| c.delete('"') } + values = matches[2].split(", ").map { |v| v.delete("'") } + columns.zip(values).to_h + end + parsed_sql = parse_sql(audit_sql) # expect(parsed_sql["auditable_id"]).to eq("1") expect(parsed_sql["auditable_type"]).to eq("Models::ActiveRecord::User") expect(parsed_sql["action"]).to eq("update") @@ -462,6 +465,20 @@ class CallbacksSpecified < ::ActiveRecord::Base expect(last_audit.action).to eq("update") expect(last_audit.audited_changes).to eq({"name" => ["Brandon", "Changed"]}) expect(last_audit.version).to eq(2) + + @user.assign_attributes(name: "Changed-2") + expect { ActiveRecord::Base.connection.execute(@user.audit_sql) }.to change(@user.audits, :count).by(1) + expect { @user.class.where(id: @user.id).update_all(name: "Changed-2") }.not_to change(@user.audits, :count) + expect { @user.reload.save! }.not_to change(@user.audits, :count) + + expect { @user.update!(name: "Changed-3") }.to change(@user.audits, :count).by(1) + expect(@user.audits.last.version).to eq(4) + + destroy_sql = parse_sql(@user.audit_sql(destroy: true)) + expect(destroy_sql["action"]).to eq("destroy") + + @user.assign_attributes(name: "Changed-4") + expect { @user.audit_sql }.not_to change(@user.audits, :count) end context "with readonly attributes" do