Skip to content

Commit

Permalink
Allow to specify custom scoping for included associations
Browse files Browse the repository at this point in the history
  • Loading branch information
Envek committed Dec 6, 2024
1 parent e404700 commit 513f167
Show file tree
Hide file tree
Showing 10 changed files with 102 additions and 13 deletions.
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- Print reason of association exclusion or inclusion in verbose mode. [@Envek]

- Allow to apply custom scoping to included associations. [@Envek]

```ruby
config.root('Forum', featured: true) do |forum|
forum.include('questions.answers') do
order(created_at: :desc)
end
end
```

### Fixed

- Bug with null foreign key to back to auxiliary `has_one` association with not matching names. E.g. user has many profiles and has one default profile, profile belongs to user.
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ EvilSeed.configure do |config|
# First, you should specify +root models+ and their +constraints+ to limit the number of dumped records:
# This is like Forum.where(featured: true).all
config.root('Forum', featured: true) do |root|
# You can limit number of records to be dumped
root.limit(100)
# Specify order for records to be selected for dump
root.order(created_at: :desc)

# It's possible to remove some associations from dumping with pattern of association path to exclude
#
# Association path is a dot-delimited string of association chain starting from model itself:
Expand All @@ -59,6 +64,11 @@ EvilSeed.configure do |config|
# which is the same as
root.include(/\Aforum(\.parent(\.questions(\.(answers|votes))?)?)?\z/)

# You can also specify custom scoping for associations
root.include(questions: { answers: :reactions }) do
order(created_at: :desc) # Any ActiveRecord query method is allowed
end

# It's possible to limit the number of included into dump has_many and has_one records for every association
# Note that belongs_to records for all not excluded associations are always dumped to keep referential integrity.
root.limit_associations_size(100)
Expand Down
26 changes: 20 additions & 6 deletions lib/evil_seed/configuration/root.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ module EvilSeed
class Configuration
# Configuration for dumping some root model and its associations
class Root
attr_reader :model, :constraints
attr_reader :model, :constraints, :limit, :order
attr_reader :total_limit, :association_limits, :deep_limit, :dont_nullify
attr_reader :exclusions, :inclusions

Expand All @@ -14,7 +14,7 @@ def initialize(model, dont_nullify, *constraints)
@model = model
@constraints = constraints
@exclusions = []
@inclusions = []
@inclusions = {}
@association_limits = {}
@deep_limit = nil
@dont_nullify = dont_nullify
Expand All @@ -36,14 +36,16 @@ def exclude(*association_patterns)

# Include some excluded associations back to the dump
# @param association_patterns Array<String, Regex> Patterns to exclude associated models from dump
def include(*association_patterns)
def include(*association_patterns, &block)
association_patterns.each do |pattern|
case pattern
when String, Regexp
@inclusions << pattern
@inclusions[pattern] = block
else
path_prefix = model.constantize.model_name.singular
@inclusions += compile_patterns(pattern, prefix: path_prefix).map { |p| Regexp.new(/\A#{p}\z/) }
compile_patterns(pattern, prefix: path_prefix).map do |p|
@inclusions[Regexp.new(/\A#{p}\z/)] = block
end
end
end
end
Expand All @@ -56,6 +58,18 @@ def exclude_optional_belongs_to
@excluded_optional_belongs_to = :exclude_optional_belongs_to
end

def limit(limit = nil)
return @limit if limit.nil?

@limit = limit
end

def order(order = nil)
return @order if order.nil?

@order = order
end

# Limit number of records in all (if pattern is not provided) or given associations to include into dump
# @param limit [Integer] Maximum number of records in associations to include into dump
# @param association_pattern [String, Regex] Pattern to limit number of records for certain associated models
Expand All @@ -82,7 +96,7 @@ def excluded?(association_path)
end

def included?(association_path)
inclusions.find { |inclusion| association_path.match(inclusion) } #.match(association_path) }
inclusions.find { |inclusion, _block| association_path.match(inclusion) }
end

def excluded_has_relations?
Expand Down
25 changes: 19 additions & 6 deletions lib/evil_seed/relation_dumper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ class RelationDumper
attr_reader :relation, :root_dumper, :model_class, :association_path, :search_key, :identifiers, :nullify_columns,
:belongs_to_reflections, :has_many_reflections, :foreign_keys, :loaded_ids, :to_load_map,
:record_dumper, :inverse_reflection, :table_names, :options,
:current_deep, :verbose
:current_deep, :verbose, :custom_scope

delegate :root, :configuration, :dont_nullify, :total_limit, :deep_limit, :loaded_map, to: :root_dumper

Expand All @@ -52,6 +52,7 @@ def initialize(relation, root_dumper, association_path, **options)
@options = options
@current_deep = association_path.split('.').size
@dont_nullify = dont_nullify
@custom_scope = options[:custom_scope]
end

# Generate dump and write it into +io+
Expand All @@ -73,7 +74,15 @@ def dump!
original_ignored_columns = model_class.ignored_columns
model_class.ignored_columns += Array(configuration.ignored_columns_for(model_class.sti_name))
model_class.send(:reload_schema_from_cache) if ActiveRecord.version < Gem::Version.new("6.1.0.rc1") # See https://github.com/rails/rails/pull/37581
if identifiers.present?
if custom_scope
puts(" # #{search_key} (with scope)") if verbose
attrs = fetch_attributes(relation)
puts(" -- dumped #{attrs.size}") if verbose
attrs.each do |attributes|
next unless check_limits!
dump_record!(attributes)
end
elsif identifiers.present?
puts(" # #{search_key} => #{identifiers}") if verbose
# Don't use AR::Base#find_each as we will get error on Oracle if we will have more than 1000 ids in IN statement
identifiers.in_groups_of(MAX_IDENTIFIERS_IN_IN_STMT).each do |ids|
Expand Down Expand Up @@ -129,16 +138,17 @@ def dump_belongs_to_associations!
end

def dump_has_many_associations!
has_many_reflections.map do |reflection|
has_many_reflections.map do |reflection, custom_scope|
next if loaded_ids.empty? || total_limit.try(:zero?)
RelationDumper.new(
build_relation(reflection),
build_relation(reflection, custom_scope),
root_dumper,
"#{association_path}.#{reflection.name}",
search_key: reflection.foreign_key,
identifiers: loaded_ids,
inverse_of: reflection.inverse_of.try(:name),
limitable: true,
custom_scope: custom_scope,
).call
end
end
Expand All @@ -157,13 +167,14 @@ def check_limits!
root_dumper.check_limits!(association_path)
end

def build_relation(reflection)
def build_relation(reflection, custom_scope = nil)
if configuration.unscoped
relation = reflection.klass.unscoped
else
relation = reflection.klass.all
end
relation = relation.instance_eval(&reflection.scope) if reflection.scope
relation = relation.instance_eval(&custom_scope) if custom_scope
relation = relation.where(reflection.type => model_class.to_s) if reflection.options[:as] # polymorphic
relation
end
Expand Down Expand Up @@ -205,7 +216,9 @@ def setup_has_many_reflections
excluded ||= root.excluded?("#{association_path}.#{reflection.name}")
puts " -- #{reflection.macro} #{reflection.name} #{"excluded by #{excluded}" if excluded} #{"re-included by #{included}" if included}" if verbose
!(excluded and not included)
end.map(&:second)
end.map do |_reflection_name, reflection|
[reflection, root.included?("#{association_path}.#{reflection.name}")&.last]
end
end
end
end
2 changes: 2 additions & 0 deletions lib/evil_seed/root_dumper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ def call
relation = model_class.all
relation = relation.unscoped if configuration.unscoped
relation = relation.where(*root.constraints) if root.constraints.any? # without arguments returns not a relation
relation = relation.limit(root.limit) if root.limit
relation = relation.order(root.order) if root.order
RelationDumper.new(relation, self, association_path).call
end

Expand Down
8 changes: 8 additions & 0 deletions test/db/models.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ class User < ActiveRecord::Base
has_many :questions, foreign_key: :author_id
has_many :answers, foreign_key: :author_id
has_many :votes
has_many :reactions

has_and_belongs_to_many :roles, join_table: :user_roles
end
Expand Down Expand Up @@ -38,6 +39,7 @@ class Question < ActiveRecord::Base

has_many :answers
has_many :votes, as: :votable
has_many :reactions, as: :reactable

has_many :voters, through: :votes, source: :user

Expand All @@ -48,6 +50,7 @@ class Answer < ActiveRecord::Base
belongs_to :author, class_name: 'User'

has_many :votes, as: :votable
has_many :reactions, as: :reactable

has_many :voters, through: :votes, source: :user

Expand All @@ -62,3 +65,8 @@ class Vote < ActiveRecord::Base
belongs_to :votable, polymorphic: true
belongs_to :user
end

class Reaction < ActiveRecord::Base
belongs_to :votable, polymorphic: true
belongs_to :user
end
7 changes: 7 additions & 0 deletions test/db/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,13 @@ def create_schema!
t.references :user, foreign_key: { on_delete: :nullify }
end

create_table :reactions do |t|
t.references :reactable, polymorphic: true
t.references :user, foreign_key: { on_delete: :nullify }
t.string :reaction
t.timestamps null: false
end

create_table :roles do |t|
t.string :name
t.string :permissions, **(ENV["DB"] == "postgresql" ? { array: true } : {})
Expand Down
6 changes: 5 additions & 1 deletion test/db/seeds.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,16 @@

question.votes.create!(user: User.find_by!(login: 'alice'))

question.answers.create!(
answer = question.answers.create!(
text: 'Oh please go on hack it, ROFL)))',
best: true,
author: User.find_by!(login: 'alice'),
)

answer.reactions.create!(users.map.with_index do |user, i|
{ user: user, reaction: ':+1:', created_at: Time.current - 1.hour + i.minutes }
end)

answer = question.answers.create!(
text: 'Please, stop',
author: User.find_by!(login: 'bob'),
Expand Down
20 changes: 20 additions & 0 deletions test/evil_seed/dumper_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ def test_it_dumps_tree_structures_with_foreign_keys
root.exclude('forum.users')
root.exclude(/parent\.users/)
root.exclude(/role\..+/)
root.exclude(/\.reactions\b/)
end
configuration.customize('User') do |attributes|
attributes['password'] = '12345678'
Expand Down Expand Up @@ -77,5 +78,24 @@ def test_it_applies_unscoping_and_inclusions
assert_match(/'Descendant forum'/, result)
assert_match(/'Oops, I was wrong'/, result)
end

def test_it_applies_custom_scopes
configuration = EvilSeed::Configuration.new
configuration.root('Forum', name: 'Descendant forum') do |root|
root.include(parent: {questions: :answers })
root.include("forum.parent.questions.answers.reactions") do
order(created_at: :desc).limit(2)
end
root.exclude(/.\..+/)
end

io = StringIO.new
EvilSeed::Dumper.new(configuration).call(io)
result = io.string
File.write(File.join('tmp', "#{__method__}.sql"), result)
assert io.closed?
assert_match("':+1:'", result)
assert_equal(2, result.scan("':+1:'").size)
end
end
end
1 change: 1 addition & 0 deletions test/evil_seed_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ def setup
root.exclude(/parent\.users/)
root.exclude(/role\..+/)
root.exclude(/\.profiles/)
root.exclude(/\.reactions\b/)
end
config.root('Question') do |root|
root.exclude(/.*/)
Expand Down

0 comments on commit 513f167

Please sign in to comment.