-
Notifications
You must be signed in to change notification settings - Fork 178
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
Refactor Relation
and conditions to fix mutating scopes issues
#268
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,44 @@ | ||
class ActiveHash::Relation::Condition | ||
attr_reader :constraints, :inverted | ||
|
||
def initialize(constraints) | ||
@constraints = constraints | ||
@inverted = false | ||
end | ||
|
||
def invert! | ||
@inverted = !inverted | ||
|
||
self | ||
end | ||
|
||
def matches?(record) | ||
match = begin | ||
return true unless constraints | ||
|
||
expectation_method = inverted ? :any? : :all? | ||
|
||
constraints.send(expectation_method) do |attribute, expected| | ||
value = record.public_send(attribute) | ||
|
||
matches_value?(value, expected) | ||
end | ||
end | ||
|
||
inverted ? !match : match | ||
end | ||
|
||
private | ||
|
||
def matches_value?(value, comparison) | ||
return comparison.any? { |v| matches_value?(value, v) } if comparison.is_a?(Array) | ||
return comparison.include?(value) if comparison.is_a?(Range) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. could you squash the third commit into this commit. no reason to introduce a bug to then fix it in a later commit. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sure, but if this PR is eventually merged via a single squashed commit, I think it's good to keep the individual commit in this PR for future clarity. Happy to squash if you guys plan merging via merge-commits and not squash and merge strategy. |
||
return comparison.match?(value) if comparison.is_a?(Regexp) | ||
|
||
normalize(value) == normalize(comparison) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could you talk more on this? The first thing I tested was: (1.0).to_s == 1.to_s But cases like this work great (which are probably more relevant since this will fix the very common "4".to_s == "4".to_s There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes; this is to make ActiveHash behave similar to how ActiveRecord behaves for querying. ActiveRecord (or rather the DB) does a similar typecasting when querying: # AR
User.find("2").id # => 2
User.find_by(id: "2").id # => 2
User.find_by(username: :john).username # => "john" The # AH
City.find("2").id # => 2
City.find_by(id: "2").id # => 2
City.find_by(country_code: :gb).country_code # => "gb" .. without this normalization, no records would be returned in the examples above for AH, while it would for AR. |
||
end | ||
|
||
def normalize(value) | ||
value.respond_to?(:to_s) ? value.to_s : value | ||
end | ||
end |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,21 @@ | ||
class ActiveHash::Relation::Conditions | ||
attr_reader :conditions | ||
|
||
delegate_missing_to :conditions | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. trying to wrap my mind around this one. I guess real active record does something similar any time you run methods on relations. Can you just put a comment to explain this a little more? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sure. This class is an delegator to an array of conditions. It delegates methods to the array of conditions, but also implements It also adds Happy to add comments to the code, if you believe this would make it clearer. |
||
|
||
def initialize(conditions = []) | ||
@conditions = conditions | ||
end | ||
|
||
def matches?(record) | ||
conditions.all? do |condition| | ||
condition.matches?(record) | ||
end | ||
end | ||
|
||
def self.wrap(conditions) | ||
return conditions if conditions.is_a?(self) | ||
|
||
new(conditions) | ||
end | ||
end |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -7,24 +7,80 @@ class Relation | |
delegate :empty?, :length, :first, :second, :third, :last, to: :records | ||
delegate :sample, to: :records | ||
|
||
def initialize(klass, all_records, query_hash = nil) | ||
attr_reader :conditions, :order_values, :klass, :all_records | ||
|
||
def initialize(klass, all_records, conditions = nil, order_values = nil) | ||
self.klass = klass | ||
self.all_records = all_records | ||
self.query_hash = query_hash | ||
self.records_dirty = false | ||
self.conditions = Conditions.wrap(conditions || []) | ||
self.order_values = order_values || [] | ||
end | ||
|
||
def where(conditions_hash = :chain) | ||
return WhereChain.new(self) if conditions_hash == :chain | ||
|
||
spawn.where!(conditions_hash) | ||
end | ||
|
||
class WhereChain | ||
attr_reader :relation | ||
|
||
def initialize(relation) | ||
@relation = relation | ||
end | ||
|
||
def not(conditions_hash) | ||
relation.conditions << Condition.new(conditions_hash).invert! | ||
relation | ||
end | ||
end | ||
|
||
def order(*options) | ||
spawn.order!(*options) | ||
end | ||
|
||
def reorder(*options) | ||
spawn.reorder!(*options) | ||
end | ||
|
||
def where!(conditions_hash, inverted = false) | ||
self.conditions << Condition.new(conditions_hash) | ||
self | ||
end | ||
|
||
def where(query_hash = :chain) | ||
return ActiveHash::Base::WhereChain.new(self) if query_hash == :chain | ||
def spawn | ||
self.class.new(klass, all_records, conditions, order_values) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. is this ok to not I think thinking out loud (and not a request): There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Passing around In theory, it could probably be possible to re-load all the records from the main class when a relation is unscoped, but I have a feeling that it would make the behavior of calling Let me know if this is something I should look into. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. thanks. if it ain't broke... |
||
end | ||
|
||
def order!(*options) | ||
check_if_method_has_arguments!(:order, options) | ||
self.order_values += preprocess_order_args(options) | ||
self | ||
end | ||
|
||
def reorder!(*options) | ||
check_if_method_has_arguments!(:order, options) | ||
|
||
self.order_values = preprocess_order_args(options) | ||
@records = apply_order_values(records, order_values) | ||
|
||
self.records_dirty = true unless query_hash.nil? || query_hash.keys.empty? | ||
self.query_hash.merge!(query_hash || {}) | ||
self | ||
end | ||
|
||
def records | ||
@records ||= begin | ||
filtered_records = apply_conditions(all_records, conditions) | ||
ordered_records = apply_order_values(filtered_records, order_values) # rubocop:disable Lint/UselessAssignment | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Seems it would have been easier to remove Is this for future debugging or something? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You're right, probably had a breakpoint here at one point to inspect :-) |
||
end | ||
end | ||
|
||
def reload | ||
@records = nil # Reset records | ||
self | ||
end | ||
|
||
def all(options = {}) | ||
if options.has_key?(:conditions) | ||
if options.key?(:conditions) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You did not write this code, so probably ignore this but... is there a reason to have the conditional here instead of an where(options[:conditions] || {}) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There would be a difference when passing an explicit false-y ( But not sure what effect it has for the finders though. |
||
where(options[:conditions]) | ||
else | ||
where({}) | ||
|
@@ -58,10 +114,11 @@ def find(id = nil, *args, &block) | |
end | ||
|
||
def find_by_id(id) | ||
return where(id: id).first if query_hash.present? | ||
|
||
index = klass.send(:record_index)[id.to_s] # TODO: Make index in Base publicly readable instead of using send? | ||
index and records[index] | ||
return unless index | ||
|
||
record = all_records[index] | ||
record if conditions.matches?(record) | ||
end | ||
|
||
def count | ||
|
@@ -84,86 +141,32 @@ def pick(*column_names) | |
pluck(*column_names).first | ||
end | ||
|
||
def reload | ||
@records = filter_all_records_by_query_hash | ||
end | ||
|
||
def order(*options) | ||
check_if_method_has_arguments!(:order, options) | ||
relation = where({}) | ||
return relation if options.blank? | ||
|
||
processed_args = preprocess_order_args(options) | ||
candidates = relation.dup | ||
|
||
order_by_args!(candidates, processed_args) | ||
|
||
candidates | ||
end | ||
|
||
def to_ary | ||
records.dup | ||
end | ||
|
||
def method_missing(method_name, *args) | ||
return super unless self.klass.scopes.key?(method_name) | ||
return super unless klass.scopes.key?(method_name) | ||
|
||
instance_exec(*args, &self.klass.scopes[method_name]) | ||
instance_exec(*args, &klass.scopes[method_name]) | ||
end | ||
|
||
attr_reader :query_hash, :klass, :all_records, :records_dirty | ||
|
||
private | ||
|
||
attr_writer :query_hash, :klass, :all_records, :records_dirty | ||
|
||
def records | ||
if !defined?(@records) || @records.nil? || records_dirty | ||
reload | ||
else | ||
@records | ||
end | ||
def respond_to_missing?(method_name, include_private = false) | ||
klass.scopes.key?(method_name) || super | ||
end | ||
|
||
def filter_all_records_by_query_hash | ||
self.records_dirty = false | ||
return all_records if query_hash.blank? | ||
|
||
# use index if searching by id | ||
if query_hash.key?(:id) || query_hash.key?("id") | ||
ids = (query_hash.delete(:id) || query_hash.delete("id")) | ||
ids = range_to_array(ids) if ids.is_a?(Range) | ||
candidates = Array.wrap(ids).map { |id| klass.find_by_id(id) }.compact | ||
end | ||
private | ||
|
||
return candidates if query_hash.blank? | ||
attr_writer :conditions, :order_values, :klass, :all_records | ||
|
||
(candidates || all_records || []).select do |record| | ||
match_options?(record, query_hash) | ||
end | ||
end | ||
def apply_conditions(records, conditions) | ||
return records if conditions.blank? | ||
|
||
def match_options?(record, options) | ||
options.all? do |col, match| | ||
if match.kind_of?(Array) | ||
match.any? { |v| normalize(v) == normalize(record[col]) } | ||
else | ||
normalize(match) === normalize(record[col]) | ||
end | ||
records.select do |record| | ||
conditions.matches?(record) | ||
end | ||
end | ||
|
||
def normalize(v) | ||
v.respond_to?(:to_sym) ? v.to_sym : v | ||
end | ||
|
||
def range_to_array(range) | ||
return range.to_a unless range.end.nil? | ||
|
||
e = records.last[:id] | ||
(range.begin..e).to_a | ||
end | ||
|
||
def check_if_method_has_arguments!(method_name, args) | ||
return unless args.blank? | ||
|
||
|
@@ -179,7 +182,9 @@ def preprocess_order_args(order_args) | |
ary.map! { |e| e.split(/\W+/) }.reverse! | ||
end | ||
|
||
def order_by_args!(candidates, args) | ||
def apply_order_values(records, args) | ||
ordered_records = records.dup | ||
|
||
args.each do |arg| | ||
field, dir = if arg.is_a?(Hash) | ||
arg.to_a.flatten.map(&:to_sym) | ||
|
@@ -189,14 +194,16 @@ def order_by_args!(candidates, args) | |
arg.to_sym | ||
end | ||
|
||
candidates.sort! do |a, b| | ||
ordered_records.sort! do |a, b| | ||
if dir.present? && dir.to_sym.upcase.equal?(:DESC) | ||
b[field] <=> a[field] | ||
else | ||
a[field] <=> b[field] | ||
end | ||
end | ||
end | ||
|
||
ordered_records | ||
end | ||
end | ||
end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
is there a reason to change this block?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I suppose the
Relation
could be initialized with the conditions directly.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The initializer has:
I wondered if there was a reason to not use that.
The new code passes a nil (so it gets and empty array)
and the
where!
then adds to that conditions.Is this to ensure we don't modify the
options[:conditions]
?I kinda like the original way better but if this has a reason then 👍