Class: ActiveSanction::Diff::Change

Inherits:
Object
  • Object
show all
Extended by:
T::Sig
Defined in:
lib/active_sanction/diff/change.rb

Overview

One entity that is on both snapshots and is not the same on each, with the fields that moved.

change.entity    # => Entity, as the new list has it
change.previous  # => Entity, as the old list had it
change.fields    # => [:names, :programs]
change.changes
# => { names:    { added: [#<Name "ZAYDAN, Muhammad">], removed: [] },
#      programs: { added: ["SDGT"], removed: [] } }

puts change      # => ofac_sdn:2674  names +1, programs +1

Why an amendment is not a delisting plus a listing

Governments amend far more records than they publish or withdraw: a passport number is corrected, an alias is added, a program is amended. Reporting one of those as a removal followed by an addition puts a delisting in front of an analyst that never happened -- and a delisting is the entry a compliance team acts on, since it is the one that lets a customer back through the door. So the two snapshots are joined by entity id and only what actually moved is reported, which is what makes id stability a conformance requirement for every adapter (#16) rather than a nicety.

Collections are compared as sets

names, addresses, identifiers, dates_of_birth, nationalities and programs are compared by membership rather than position: a publisher that re-emits the same four aliases in a different order has not amended the record, and a diff that says it has costs somebody a review. Everything else is a scalar and is reported as from and to.

FIELDS is derived from Entity::MEMBERS rather than written out, so a field added to the canonical record is compared here without anyone having to remember to add it. A new collection still has to be named in COLLECTIONS -- until it is, it is compared whole, which is a coarse answer rather than a silently missing one.

Instances are frozen on construction and compare by value.

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(previous:, entity:, changes:) ⇒ void

Parameters:

  • previous (T.untyped)
  • entity (T.untyped)
  • changes (T.untyped)


117
118
119
120
121
122
# File 'lib/active_sanction/diff/change.rb', line 117

def initialize(previous:, entity:, changes:)
  @previous = T.let(previous, T.untyped)
  @entity = T.let(entity, T.untyped)
  @changes = T.let(changes.freeze, T::Hash[Symbol, T::Hash[Symbol, T.untyped]])
  freeze
end

Instance Attribute Details

#changes ⇒ Hash{Symbol => Hash{Symbol => T.untyped}} (readonly)

Field to detail, in Entity's member order. A collection field carries { added:, removed: } and a scalar { from:, to: }.

Returns:

  • (Hash{Symbol => Hash{Symbol => T.untyped}})


86
87
88
# File 'lib/active_sanction/diff/change.rb', line 86

def changes
  @changes
end

#entity ⇒ T.untyped (readonly)

The entity as the new snapshot has it.

Returns:

  • (T.untyped)


77
78
79
# File 'lib/active_sanction/diff/change.rb', line 77

def entity
  @entity
end

#previous ⇒ T.untyped (readonly)

The entity as the old snapshot had it.

Returns:

  • (T.untyped)


81
82
83
# File 'lib/active_sanction/diff/change.rb', line 81

def previous
  @previous
end

Class Method Details

.between(previous, current) ⇒ T.attached_class?

The change between two versions of one entity, or nil when they say the same thing. Nil rather than an empty change: "this record was amended" and "this record was re-published unchanged" are different answers, and only one of them is worth an analyst's time.

Parameters:

  • previous (T.untyped)
  • current (T.untyped)

Returns:

  • (T.attached_class, nil)


93
94
95
96
97
98
99
# File 'lib/active_sanction/diff/change.rb', line 93

def self.between(previous, current)
  changes = FIELDS.each_with_object({}) do |field, found|
    detail = compare(field, previous.public_send(field), current.public_send(field))
    found[field] = detail if detail
  end
  changes.empty? ? nil : new(previous: previous, entity: current, changes: changes)
end

Instance Method Details

#==(other) ⇒ Boolean Also known as: eql?

Parameters:

  • other (T.untyped)

Returns:

  • (Boolean)


162
163
164
165
166
# File 'lib/active_sanction/diff/change.rb', line 162

def ==(other)
  return false unless other.instance_of?(self.class)

  to_h == other.to_h
end

#[](field) ⇒ Hash{Symbol => T.untyped}?

What moved in one field, or nil if that field did not.

Parameters:

  • field (T.untyped)

Returns:

  • (Hash{Symbol => T.untyped}, nil)


137
# File 'lib/active_sanction/diff/change.rb', line 137

def [](field) = changes[field.to_sym]

#changed?(field) ⇒ Boolean

Parameters:

  • field (T.untyped)

Returns:

  • (Boolean)


133
# File 'lib/active_sanction/diff/change.rb', line 133

def changed?(field) = changes.key?(field.to_sym)

#fields ⇒ Array<Symbol>

Returns:

  • (Array<Symbol>)


130
# File 'lib/active_sanction/diff/change.rb', line 130

def fields = changes.keys

#hash ⇒ Integer

Returns:

  • (Integer)


170
# File 'lib/active_sanction/diff/change.rb', line 170

def hash = [self.class, to_h].hash

#id ⇒ String

The id both versions share, which is the whole reason this is one record rather than two.

Returns:

  • (String)


127
# File 'lib/active_sanction/diff/change.rb', line 127

def id = entity.id

#inspect ⇒ String

Returns:

  • (String)


173
# File 'lib/active_sanction/diff/change.rb', line 173

def inspect = "#<#{self.class} #{self}>"

#summary ⇒ String

One line, for the summary a human reads:

ofac_sdn:2674  names +1 -1, programs +1, remarks "..." -> "..."

Returns:

  • (String)


156
# File 'lib/active_sanction/diff/change.rb', line 156

def summary = fields.map { |field| describe(field) }.join(", ")

#to_h ⇒ Hash{Symbol => T.untyped}

JSON-ready: every value object is serialized the way the snapshot serializes it, so a consumer that already reads entities can read a change without a second vocabulary.

Returns:

  • (Hash{Symbol => T.untyped})


143
144
145
146
147
148
149
150
# File 'lib/active_sanction/diff/change.rb', line 143

def to_h
  {
    id: id,
    entity: entity.to_h,
    previous: previous.to_h,
    changes: changes.transform_values { |detail| detail.transform_values { |value| serialize(value) } }
  }
end

#to_s ⇒ String

Returns:

  • (String)


159
# File 'lib/active_sanction/diff/change.rb', line 159

def to_s = "#{id}  #{summary}"