Module: DataMapper
- Defined in:
- lib/dm-types/support/dirty_minder.rb,
lib/dm-types.rb,
lib/dm-types/csv.rb,
lib/dm-types/uri.rb,
lib/dm-types/enum.rb,
lib/dm-types/flag.rb,
lib/dm-types/json.rb,
lib/dm-types/slug.rb,
lib/dm-types/uuid.rb,
lib/dm-types/yaml.rb,
lib/dm-types/regexp.rb,
lib/dm-types/api_key.rb,
lib/dm-types/file_path.rb,
lib/dm-types/epoch_time.rb,
lib/dm-types/ip_address.rb,
lib/dm-types/bcrypt_hash.rb,
lib/dm-types/paranoid/base.rb,
lib/dm-types/support/flags.rb,
lib/dm-types/paranoid_boolean.rb,
lib/dm-types/paranoid_datetime.rb,
lib/dm-types/comma_separated_list.rb
Overview
Approach
We need to detect whether or not the underlying Hash or Array changed and update the dirty-ness of the encapsulating Resource accordingly (so that it will actually save).
DM’s state-tracking code only triggers dirty-ness by comparing the new value against the instance’s Property’s current value. WRT mutation, we have to choose one of the following approaches:
(1) mutate a copy ("after"), then invoke the Resource assignment and State
tracking
(2) create a copy ("before"), mutate self ("after"), then invoke the
Resource assignment and State tracking
(1) seemed simpler at first, but it required additional steps to alias the original (pre-hooked) methods before overriding them (so they could be invoked externally, ala self.clone.send(“orig_…”)), and more importantly it resulted in any external references keeping their old value (instead of getting the new), like so:
copy = instance.json
copy[:some] = :value
instance.json[:some] == :value
=> true
copy[:some] == :value
=> false # fk!
In order to do (2) and still have State tracking trigger normally, we need to ensure the Property has a different value other than self when the State tracking does the comparison. This equates to setting the Property directly to the “before” value (a clone and thus a different object/value) before invoking the Resource Property/attribute assignment.
The cloning of any value might sound expensive, but it’s identical in cost to what you already had to do: assign a cloned copy in order to trigger dirty-ness (e.g. ::DataMapper::Property::Json):
model.json = model.json.merge({:some=>:value})
Hooking Core Classes
We want to hook certain methods on Hash and Array to trigger dirty-ness in the resource. However, because these are core classes, they are individually mapped to C primitives and thus cannot be hooked through #send/#__send__. We have to override each method, but we don’t want to write a lot of code.
Minimally Invasive
We also want to extend behaviour of existing class instances instead of impersonating/delegating from a proxy class of our own, or overriding a global class behaviour. This is the most flexible approach and least prone to error, since it leaves open the option for consumers to proxy or override global classes, and is less likely to interfere with method_missing/etc shenanigans.
Nested Object Mutations
Since we use Array,Hash#hash to compare before & after, and #hash accounts for/traverses nested structures, no “deep” inspection logic is technically necessary. However, Resource#dirty? only queries a cache of dirtied attributes, whose own population strategy is to hook assignment (instead of interrogating properties on demand). So the approach is still limited to top-level mutators.
Maybe consider optional “advisory” Property#dirty? method for Resource#dirty? that custom properties could use for this purpose.
TODO: add support for detecting mutations in nested objects, but we can’t
catch the assignment from here (yet?).
TODO: ensure we covered all indirectly-mutable classes that DM uses underneath
a property type
TODO: figure out how to hook core class methods on RBX (which do use #send)