Skip to content
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

Proposal: bypass the AR association for more efficient DB queries #90

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 4 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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,14 @@ add_index :account_settings, [ :account_id, :name ], :unique => true
* ActiveRecord
* ActiveSupport

## Running the tests locally

```shell
export BUNDLE_GEMFILE="gemfiles/rails5.1.gemfile"
bundle -j 6
bundle exec rspec
```

## License and copyright

Copyright 2013 Zendesk
Expand Down
87 changes: 73 additions & 14 deletions lib/property_sets/active_record_extension.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
require 'active_record'
require 'property_sets/association_wrapper'
require 'property_sets/casting'
require 'property_sets/value_reader'
require 'set'

module PropertySets
Expand Down Expand Up @@ -47,8 +49,48 @@ def property_set(association, options = {}, &block)
has_many association, **hash_opts do
# keep this damn block! -- creates association_module below
end

# Inject our association wrapper so that we can control how the association
# is accessed and loaded from the DB. This allows us to not load the entire
# dataset from the DB when we only care about a single property set entry.
#
# Alias the normal has_many association method.
#
alias_method "#{association}_without_wrapper", association
#
# An ivar to cache the association wrapper. This has to be scoped with the
# association name in order to support multiple uses of property sets
# within the same AR model.
#
association_wrapper_memo_name = prop_set_assoc_wrapper_memo_name(association)
#
# Redefine the association method.
# The AssociationProxy will use the original (aliased) association
# method when required.
#
define_method association do |**opts|
memo = instance_variable_get(association_wrapper_memo_name) and return memo

wrapper = PropertySets::AssociationWrapper.new(
owner_instance: self,
assoc_name: association,
assoc_class: property_class,
opts: opts
)
instance_variable_set(association_wrapper_memo_name, wrapper)

wrapper
end

# To replicate ":autosave => true" on the has_many association.
before_save -> do
public_send(association).save
end
end

# Open the association module defined with the has_many declaration (above)
# and define custom accessors.

# eg 5: AccountSettingsAssociationExtension
# eg 6: Account::SettingsAssociationExtension

Expand Down Expand Up @@ -96,6 +138,11 @@ def property_set(association, options = {}, &block)
end
end
end


def prop_set_assoc_wrapper_memo_name(association)
"@prop_set_assoc_wrapper_#{association}"
end
end

module AssociationExtensions
Expand Down Expand Up @@ -154,20 +201,11 @@ def lookup_without_default(arg)
detect { |property| property.name.to_sym == arg.to_sym }
end

def lookup_value(type, key)
serialized = property_serialized?(key)

if instance = lookup_without_default(key)
instance.value_serialized = serialized
PropertySets::Casting.read(type, instance.value)
else
value = association_class.default(key)
if serialized
PropertySets::Casting.deserialize(value)
else
PropertySets::Casting.read(type, value)
end
end
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This logic has been moved into PropertySets::ValueReader, but I haven't changed it.

def lookup_value(_type, key)
instance = lookup_without_default(key)
PropertySets::ValueReader.new(
property_name: key, assoc_instance: instance, assoc_class: association_class
).read
end

# The finder method which returns the property if present, otherwise a new instance with defaults
Expand All @@ -184,6 +222,13 @@ def lookup(arg)

# This finder method returns the property if present, otherwise a new instance with the default value.
# It does not have the side effect of adding a new setting object.
#
# Among the "lookup" methods, this alone is not used internally in the gem.
# It's part on the undocumented public API, and application code can make
# use of it to try to fetch a property record or an unpersisted default
# record, _without adding it to the association collection_, which means
# that the returned default will not risk to be saved.
#
def lookup_or_default(arg)
instance = lookup_without_default(arg)
instance ||= association_class.new(:value => association_class.raw_default(arg))
Expand Down Expand Up @@ -227,8 +272,22 @@ def update_columns(attributes)
super attributes
end

# Remove all the memoized Association Proxies
def reload(*args)
clear_all_property_set_caches
super(*args)
end

private

def clear_all_property_set_caches
self.class.property_set_index.each do |association|
memo_ivar = self.class.prop_set_assoc_wrapper_memo_name(association)
next unless instance_variable_defined?(memo_ivar)
remove_instance_variable(memo_ivar)
end
end

def delegated_property_sets?
self.class.respond_to?(:delegated_property_set_attributes)
end
Expand Down
Loading