Skip to content

Commit

Permalink
add denormalization to
Browse files Browse the repository at this point in the history
  • Loading branch information
ezekg committed Mar 26, 2024
1 parent af747b0 commit cf8ebce
Show file tree
Hide file tree
Showing 7 changed files with 196 additions and 34 deletions.
77 changes: 58 additions & 19 deletions app/models/concerns/denormalizable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,46 +4,85 @@ module Denormalizable
extend ActiveSupport::Concern

class_methods do
def denormalizes(attribute_name, from: nil, to: nil, with: nil, prefix: nil)
cattr_accessor :denormalized_attributes, default: Set.new

def denormalizes(attribute_name, with: nil, from: nil, to: nil, prefix: nil)
raise ArgumentError, 'must provide :from, :to, or :with (but not multiple)' unless
from.present? ^ to.present? ^ with.present?

# TODO(ezekg) Should we store more information, such as :to or :from?
denormalized_attributes << attribute_name

case
when from.present?
instrument_denormalized_attribute_from(attribute_name, from:)
instrument_denormalized_attribute_from(attribute_name, from:, prefix:)
when to.present?
raise NotImplementedError, 'denormalizes :to is not supported yet'
instrument_denormalized_attribute_to(attribute_name, to:, prefix:)
when with.present?
instrument_denormalized_attribute_with(attribute_name, with:)
raise NotImplementedError, 'denormalizes :with is not supported yet'
else
raise ArgumentError, 'must provide either :from, :to, or :with'
end
end

private

def instrument_denormalized_attribute_from(attribute_name, from:)
def instrument_denormalized_attribute_from(attribute_name, from:, prefix:)
case from
in Symbol => association_name
reflection = reflect_on_association(association_name)
in Symbol => association_name if reflection = reflect_on_association(association_name)
prefixed_attribute_name = case prefix
when true
"#{association_name.to_s}_#{attribute_name.to_s}"
when Symbol,
String
"#{prefix.to_s}_#{attribute_name.to_s}"
else
attribute_name.to_s
end

before_create -> { write_attribute(attribute_name, association(association_name).reader&.read_attribute(attribute_name) ) }, if: :"#{reflection.foreign_key}_changed?"
before_update -> { write_attribute(attribute_name, association(association_name).reader&.read_attribute(attribute_name) ) }, if: :"#{reflection.foreign_key}_changed?"
before_destroy -> { write_attribute(attribute_name, nil ) }, unless: proc { reflection.belongs_to? && !reflection.options[:optional] }
unless reflection.collection?
# FIXME(ezekg) Dedupe all of this mess.
after_initialize -> { write_attribute(prefixed_attribute_name, send(association_name)&.send(attribute_name)) }, if: :"#{reflection.foreign_key}_changed?"
before_validation -> { write_attribute(prefixed_attribute_name, send(association_name)&.send(attribute_name)) }, if: :"#{reflection.foreign_key}_changed?", on: :create
before_create -> { write_attribute(prefixed_attribute_name, send(association_name)&.send(attribute_name)) }, if: :"#{reflection.foreign_key}_changed?"
before_update -> { write_attribute(prefixed_attribute_name, send(association_name)&.send(attribute_name)) }, if: :"#{reflection.foreign_key}_changed?"
else
raise ArgumentError, "must be a singular association: #{association_name.inspect}"
end
else
raise ArgumentError, "invalid :from association: #{from.inspect}"
end
end

def instrument_denormalized_attribute_with(attribute_name, method)
# case with
# in Proc => method
# instance_exec(&method)
# in Symbol => method
# send(method)
# else
# raise ArgumentError, "invalid :with method: #{with.inspect}"
# end
def instrument_denormalized_attribute_to(attribute_name, to:, prefix:)
case to
in Symbol => association_name if reflection = reflect_on_association(association_name)
prefixed_attribute_name = case prefix
when true
"#{association_name.to_s}_#{attribute_name.to_s}"
when Symbol,
String
"#{prefix.to_s}_#{attribute_name.to_s}"
else
attribute_name.to_s
end

# FIXME(ezekg) Set to nil on destroy unless the association is dependent?
# FIXME(ezekg) Dedupe all of this mess.
if reflection.collection?
after_initialize -> { send(association_name).each { _1.write_attribute(prefixed_attribute_name, read_attribute(attribute_name)) } }, if: :"#{attribute_name}_changed?"
before_validation -> { send(association_name).each { _1.write_attribute(prefixed_attribute_name, read_attribute(attribute_name)) } }, if: :"#{attribute_name}_changed?", on: :create
before_create -> { send(association_name).update_all(prefixed_attribute_name => read_attribute(attribute_name)) }, if: :"#{attribute_name}_changed?"
before_update -> { send(association_name).update_all(prefixed_attribute_name => read_attribute(attribute_name)) }, if: :"#{attribute_name}_changed?"
else
after_initialize -> { send(association_name).write_attribute(prefixed_attribute_name, read_attribute(attribute_name)) }, if: :"#{attribute_name}_changed?"
before_validation -> { send(association_name).write_attribute(prefixed_attribute_name, read_attribute(attribute_name)) }, if: :"#{attribute_name}_changed?", on: :create
before_create -> { send(association_name).update(prefixed_attribute_name => read_attribute(attribute_name)) }, if: :"#{attribute_name}_changed?"
before_update -> { send(association_name).update(prefixed_attribute_name => read_attribute(attribute_name)) }, if: :"#{attribute_name}_changed?"
end
else
raise ArgumentError, "invalid :to association: #{to.inspect}"
end
end
end
end
3 changes: 1 addition & 2 deletions app/models/license.rb
Original file line number Diff line number Diff line change
Expand Up @@ -817,8 +817,7 @@ def reinstate!
end

def transfer!(new_policy)
self.product = new_policy&.product # denormalized
self.policy = new_policy
self.policy = new_policy

if new_policy.present? && new_policy.reset_expiry_on_transfer?
if new_policy.duration?
Expand Down
4 changes: 4 additions & 0 deletions app/models/policy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ class Policy < ApplicationRecord
class UnsupportedPoolError < StandardError; end
class EmptyPoolError < StandardError; end

include Denormalizable
include Environmental
include Accountable
include Limitable
Expand Down Expand Up @@ -143,6 +144,9 @@ class EmptyPoolError < StandardError; end
has_environment default: -> { product&.environment_id }
has_account default: -> { product&.account_id }

denormalizes :product_id,
to: :licenses

# Default to legacy encryption scheme so that we don't break backwards compat
before_validation -> { self.scheme = 'LEGACY_ENCRYPT' }, on: :create, if: -> { encrypted? && scheme.nil? }

Expand Down
67 changes: 65 additions & 2 deletions spec/models/concerns/denormalizable_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,70 @@
require 'spec_helper'

describe Denormalizable, type: :concern do
let(:account) { create(:account) }
let(:account) { create(:account) }
let(:denormalizable) {
Class.new ActiveRecord::Base do
def self.table_name = 'licenses'
def self.name = 'License'

# TODO(ezekg) Write spec
include Denormalizable

belongs_to :policy
has_many :machines
end
}

describe '.denormalizes' do
context 'when denormalizing :from' do
it 'should not raise for valid :from association' do
expect { denormalizable.denormalizes :product_id, from: :policy }.to_not raise_error
end

it 'should raise for invalid :from association' do
expect { denormalizable.denormalizes :product_id, from: :foo }.to raise_error ArgumentError
end

it 'should not raise with true :prefix' do
expect { denormalizable.denormalizes :product_id, from: :policy, prefix: true }.to_not raise_error
end

it 'should not raise with false :prefix' do
expect { denormalizable.denormalizes :product_id, from: :policy, prefix: false }.to_not raise_error
end

it 'should not raise with symbol :prefix' do
expect { denormalizable.denormalizes :product_id, from: :policy, prefix: :foo }.to_not raise_error
end
end

context 'when denormalizing :to' do
it 'should not raise for valid :to association' do
expect { denormalizable.denormalizes :product_id, to: :machines }.to_not raise_error
end

it 'should raise for invalid :to association' do
expect { denormalizable.denormalizes :product_id, to: :foo }.to raise_error ArgumentError
end

it 'should not raise with true :prefix' do
expect { denormalizable.denormalizes :product_id, to: :machines, prefix: true }.to_not raise_error
end

it 'should not raise with false :prefix' do
expect { denormalizable.denormalizes :product_id, to: :machines, prefix: false }.to_not raise_error
end

it 'should not raise with symbol :prefix' do
expect { denormalizable.denormalizes :product_id, to: :machines, prefix: :foo }.to_not raise_error
end
end

it 'should raise for :with' do
expect { denormalizable.denormalizes :product_id, with: :foo }.to raise_error NotImplementedError
end

it 'should raise for missing args' do
expect { denormalizable.denormalizes :product_id }.to raise_error ArgumentError
end
end
end
31 changes: 31 additions & 0 deletions spec/models/license_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,37 @@
end
end

describe '#policy=' do
context 'on build' do
it 'should denormalize product from policy' do
policy = create(:policy, account:)
license = build(:license, policy:, account:)

expect(license.product_id).to eq policy.product_id
end
end

context 'on create' do
it 'should denormalize product from policy' do
policy = create(:policy, account:)
license = create(:license, policy:, account:)

expect(license.product_id).to eq policy.product_id
end
end

context 'on update' do
it 'should denormalize product from policy' do
policy = create(:policy, account:)
license = create(:license, account:)

license.update!(policy:)

expect(license.product_id).to eq policy.product_id
end
end
end

describe '#role_attributes=' do
it 'should set role and permissions' do
license = create(:license, account:)
Expand Down
37 changes: 37 additions & 0 deletions spec/models/policy_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,41 @@
end
end
end

describe '#product=' do
context 'on build' do
it 'should denormalize product to licenses' do
product = create(:product, account:)
policy = build(:policy, product:, account:, licenses: build_list(:license, 10, account:))

policy.licenses.each do |license|
expect(license.product_id).to eq policy.product_id
end
end
end

context 'on create' do
it 'should denormalize product to licenses' do
product = create(:product, account:)
policy = create(:policy, product:, account:, licenses: build_list(:license, 10, account:))

policy.licenses.each do |license|
expect(license.product_id).to eq policy.product_id
end
end
end

context 'on update' do
it 'should denormalize product to licenses' do
product = create(:product, account:)
policy = create(:policy, account:, licenses: build_list(:license, 10, account:))

policy.update!(product:)

policy.licenses.each do |license|
expect(license.product_id).to eq policy.product_id
end
end
end
end
end
11 changes: 0 additions & 11 deletions spec/shared/denormalizable.rb

This file was deleted.

0 comments on commit cf8ebce

Please sign in to comment.