through_hierarchy 0.0.2 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 5ff191d3b67d67e1cdf99ba283b12873aa36624a
4
- data.tar.gz: 879a24e56dec39d507381d20a57b89080d09453c
3
+ metadata.gz: 24802dbaa712d4bce228409d6559bd95e8d07088
4
+ data.tar.gz: 94bc36b57d75022bcd256dae8edfd4710652aedf
5
5
  SHA512:
6
- metadata.gz: 8f0e6ed2807d23ebbb68813605bd34314ddba1c5a920e6c9d8d9684d2ff9eb529d5236698b439afc9430ef1ca8dd8f1144c10c9a8fc0add31c28bb48c9eb1cc3
7
- data.tar.gz: 6397865dcf63f039c3ade88eb8ce90bbbf8ba639480872a8f54f59fea59d43d9edfce07357da808b61407a0cb87fdd80cf39d6a476144caa2c3a44fdfa0cb066
6
+ metadata.gz: af92986a50caae9db2e72c5fd5e2999ed13130faef98e66fe63c97ae6281673b095a82e2a3012909bb94beabe677cd89e7e6aa518272a82554efb5fa9f77c312
7
+ data.tar.gz: efac0a679e68aae89833613b84cd1b4f636e8de80c1ef9546dc7c87386f1bd0d64516eea522936709289ea73737abe45c206788ee883c372402956ea78c22d6e
@@ -5,34 +5,23 @@ module ThroughHierarchy
5
5
  @name = name
6
6
  @model = model
7
7
  @members = members
8
- @as = options[:as].to_s
9
- @scope = options[:scope]
10
- @foreign_class_name = options[:class_name] || @name.to_s.classify
11
- @uniq = options[:uniq]
12
8
 
9
+ set_options(options)
13
10
  validate_options
11
+
12
+ @associated = Hierarchicals::Hierarchical.new(foreign_arel_table, model, members, as: @polymorphic_name)
14
13
  end
15
14
 
16
15
  def find(instance)
17
- return uniq_find(instance) if @uniq.present?
18
-
19
- results = foreign_class.where(arel_instance_filters(instance))
16
+ results = get_matches(instance)
20
17
  results = results.instance_exec(&@scope) if @scope.present?
21
18
  return results
22
19
  end
23
20
 
24
- def joins
25
- # TODO: make this work
26
- # joins = @model.arel_table.join(foreign_arel_table).on(arel_model_filters)
27
-
28
- # joins = joins.join(arel_uniq_subquery).on(
29
- # arel_hierarchy_rank.eq(uniq_subquery_arel_table[best_hierarchy_match_name]).
30
- # and(foreign_arel_table[@uniq].eq(uniq_subquery_arel_table[@uniq]))
31
- # ) if @uniq.present?
32
-
33
- # results = @model.joins(joins.join_sources)
34
- # results = results.merge(foreign_class.instance_exec(&@scope)) if @scope.present?
35
- # return results
21
+ def join
22
+ results = get_joins
23
+ results = results.merge(foreign_class.instance_exec(&@scope)) if @scope.present?
24
+ return results
36
25
  end
37
26
 
38
27
  def create(member, attributes)
@@ -41,132 +30,43 @@ module ThroughHierarchy
41
30
 
42
31
  private
43
32
 
33
+ def set_options(options)
34
+ @polymorphic_name = options[:as].to_s
35
+ @scope = options[:scope]
36
+ @foreign_class_name = options[:class_name] || @name.to_s.classify
37
+ end
38
+
44
39
  def validate_options
45
- @as.present? or raise ThroughHierarchyDefinitionError, "Must provide polymorphic `:as` options for through_hierarchy"
40
+ @polymorphic_name.present? or raise ThroughHierarchyDefinitionError, "Must provide polymorphic `:as` options for through_hierarchy"
46
41
  @model.is_a?(Class) or raise ThroughHierarchyDefinitionError, "Expected: class, got: #{@model.class}"
47
42
  @model < ActiveRecord::Base or raise ThroughHierarchyDefinitionError, "Expected: ActiveRecord::Base descendant, got: #{@model}"
48
43
  @scope.blank? || @scope.is_a?(Proc) or raise ThroughHierarchyDefinitionError, "Expected scope to be a Proc, got #{@scope.class}"
49
44
  end
50
45
 
51
- def foreign_class
52
- @foreign_class_name.constantize
53
- end
54
-
55
- def foreign_arel_table
56
- foreign_class.arel_table
57
- end
58
-
59
- def sql_hierarchy_rank
60
- "CASE `#{foreign_class.table_name}`.`#{foreign_type_field}` " +
61
- hierarchy_models.map.with_index{|m, ii| "WHEN #{@model.sanitize(model_type_constraint(m))} THEN #{ii} "}.join +
62
- "END"
63
- end
64
-
65
- def arel_hierarchy_rank
66
- Arel.sql(sql_hierarchy_rank)
67
- end
68
-
69
- # TODO: generate this dynamically based on existing columns and selects
70
- def best_hierarchy_match_name
71
- "through_hierarchy_match"
72
- end
73
-
74
- def arel_best_hierarchy_member
75
- arel_hierarchy_rank.minimum.as(best_hierarchy_match_name)
76
- end
77
-
78
- # TODO: for model level joins, subquery needs to join to all hierarchy tables
79
- def arel_uniq_subquery(instance = nil)
80
- foreign_arel_table.
81
- project(foreign_arel_table[Arel.star], arel_best_hierarchy_member).
82
- where(instance.present? ? arel_instance_filters(instance) : arel_model_filters).
83
- group(foreign_arel_table[@uniq]).
84
- as(uniq_subquery_alias)
85
- end
86
-
87
- def uniq_subquery_alias
88
- "through_hierarchy_subtable"
89
- end
90
-
91
- def uniq_subquery_arel_table
92
- Arel::Table.new(uniq_subquery_alias)
93
- end
94
-
95
- # TODO: build join sources for model-level query
96
- def uniq_subquery_join_sources(instance = nil)
97
- foreign_arel_table.
98
- join(arel_uniq_subquery(instance)).
99
- on(
100
- arel_hierarchy_rank.eq(uniq_subquery_arel_table[best_hierarchy_match_name]).
101
- and(foreign_arel_table[@uniq].eq(uniq_subquery_arel_table[@uniq]))
102
- ).join_sources
103
- end
104
-
105
- def uniq_find(instance)
106
- join_sources = uniq_subquery_join_sources(instance)
107
-
108
- return foreign_class.
109
- joins(uniq_subquery_join_sources(instance)).
110
- where(arel_instance_filters(instance)).
111
- order(foreign_arel_table[@uniq])
112
- end
113
-
114
- def hierarchy_models
115
- [@model] + @members.map{|m| @model.reflect_on_association(m).klass}
116
- end
117
-
118
- def hierarchy_instances(instance)
119
- [instance] + @members.map{|m| instance.association(m).load_target}
120
- end
121
-
122
- def foreign_key_field
123
- @as.foreign_key
124
- end
125
-
126
- def foreign_type_field
127
- @as + "_type"
128
- end
129
-
130
- def arel_foreign_key_field
131
- foreign_arel_table[foreign_key_field]
132
- end
133
-
134
- def arel_foreign_type_field
135
- foreign_arel_table[foreign_type_field]
136
- end
137
-
138
- def arel_model_filters
139
- hierarchy_models.map{|model| arel_model_filter(model)}.reduce{|q, cond| q.or(cond)}
140
- end
141
-
142
- def arel_instance_filters(instance)
143
- hierarchy_instances(instance).map{|instance| arel_instance_filter(instance)}.reduce{|q, cond| q.or(cond)}
46
+ def associated_instance(instance)
47
+ @associated.with_instance(instance)
144
48
  end
145
49
 
146
- def arel_model_filter(model)
147
- arel_foreign_type_field.eq(model_type_constraint(model)).
148
- and(arel_foreign_key_field.eq(model_key_constraint(model)))
50
+ def get_matches(instance)
51
+ return foreign_class.where(associated_instance(instance).filters)
149
52
  end
150
53
 
151
- def arel_instance_filter(instance)
152
- arel_foreign_type_field.eq(instance_type_constraint(instance)).
153
- and(arel_foreign_key_field.eq(instance_key_constraint(instance)))
54
+ # TODO: we might generate fewer join sources if we figure out which members
55
+ # are :through associations. Currently those generate redundant joins.
56
+ def get_joins
57
+ join_sources = @model.arel_table.
58
+ join(@associated.source).
59
+ on(@associated.filters).
60
+ join_sources
61
+ return @model.joins(@members + join_sources)
154
62
  end
155
63
 
156
- def instance_type_constraint(resource)
157
- resource.class.base_class.to_s
158
- end
159
-
160
- def instance_key_constraint(resource)
161
- resource.attributes[resource.class.primary_key]
162
- end
163
-
164
- def model_type_constraint(model_class)
165
- model_class.base_class.to_s
64
+ def foreign_class
65
+ @foreign_class_name.constantize
166
66
  end
167
67
 
168
- def model_key_constraint(model_class)
169
- model_class.arel_table[model_class.primary_key]
68
+ def foreign_arel_table
69
+ foreign_class.arel_table
170
70
  end
171
71
  end
172
72
  end
@@ -2,9 +2,20 @@ module ThroughHierarchy
2
2
  module Associations
3
3
  class HasOne < Association
4
4
  def find(instance)
5
- q = super
6
- q.reorder(sql_hierarchy_rank).order(q.order_values).first
5
+ matches = super
6
+ # ensure we order by hierarchy rank, but preserve scope orders
7
+ matches.reorder(@associated.hierarchy_rank).order(matches.orders).first
7
8
  end
9
+
10
+ private
11
+
12
+ def get_joins
13
+ arel = @associated.join_best_rank
14
+ result = @model.joins(arel.join_sources).order(arel.orders)
15
+ arel.constraints.each{|cc| result = result.where(cc)}
16
+ return result
17
+ end
18
+
8
19
  end
9
20
  end
10
21
  end
@@ -0,0 +1,33 @@
1
+ module ThroughHierarchy
2
+ module Associations
3
+ class HasUniq < Association
4
+ private
5
+
6
+ def set_options(options)
7
+ super
8
+ @uniq = options[:uniq]
9
+ end
10
+
11
+ # Use subquery method to select best hierarchy match for each @uniq
12
+ # Order by @uniq can result in better performance than default order (id)
13
+ def get_matches(instance)
14
+ associated_instance = @associated.with_instance(instance)
15
+ arel = @associated.with_instance(instance).select_best_rank(group_by: @uniq)
16
+ result = foreign_class.
17
+ where(associated_instance.filters).
18
+ joins(arel.join_sources).
19
+ order(arel.orders)
20
+ arel.constraints.each{|cc| result = result.where(cc)}
21
+ return result
22
+ end
23
+
24
+ def get_joins
25
+ arel = @associated.join_best_rank(group_by: @uniq)
26
+ result = @model.joins(arel.join_sources).order(arel.orders)
27
+ arel.constraints.each{|cc| result = result.where(cc)}
28
+ return result
29
+ end
30
+
31
+ end
32
+ end
33
+ end
@@ -17,6 +17,13 @@ module ThroughHierarchy
17
17
  def through_hierarchy(members, &blk)
18
18
  Hierarchy.new(self, members).instance_eval(&blk)
19
19
  end
20
+
21
+ def joins_through_hierarchy(name)
22
+ hierarchical_associations.key?(name) or raise ThroughHierarchyAssociationMissingError, "No association named #{name} was found. Perhaps you misspelled it?"
23
+ hierarchical_associations[name].join
24
+ end
25
+
26
+ # TODO: create_through_hierarchy(member = self, attributes)
20
27
  end
21
28
  end
22
29
  end
@@ -18,11 +18,6 @@ module ThroughHierarchy
18
18
  return @hierarchical_association_cache[name] if !reload && @hierarchical_association_cache.key?(name)
19
19
  @hierarchical_association_cache[name] = self.hierarchical_associations[name].find(self)
20
20
  end
21
-
22
- # TODO: join_through_hierarchy
23
-
24
- # TODO: create_from_hierarchy(member, attributes)
25
-
26
21
  end
27
22
  end
28
23
  end
@@ -4,4 +4,13 @@ module ThroughHierarchy
4
4
 
5
5
  class ThroughHierarchyDefinitionError < ThroughHierarchyError
6
6
  end
7
- end
7
+
8
+ class ThroughHierarchyAssociationMissingError < ThroughHierarchyError
9
+ end
10
+
11
+ class ThroughHierarchySourceError < ThroughHierarchyError
12
+ end
13
+
14
+ class ThroughHierarchyInstanceError < ThroughHierarchyError
15
+ end
16
+ end
@@ -0,0 +1,157 @@
1
+ module ThroughHierarchy
2
+ module Hierarchicals
3
+ class Hierarchical
4
+ attr_reader :source
5
+
6
+ # source should be an Arel::Table or Arel::TableAlias
7
+ # TODO: parent only on derived tables. Make that a separate class or module.
8
+ def initialize(source, target, hierarchy, as:, parent: nil)
9
+ @source = source
10
+ set_target(target)
11
+ @hierarchy = hierarchy
12
+ @polymorphic_name = as.to_s
13
+ @parent = parent
14
+ end
15
+
16
+ def set_target(target)
17
+ @target = target
18
+ @model = @target
19
+ end
20
+
21
+ # Initialize a new copy of self bound to a specific instance
22
+ def with_instance(instance)
23
+ instance.is_a?(@model) or raise ThroughHierarchyInstanceError, "#{instance} is not an instance of #{@model}"
24
+ Instance.new(@source, instance, @hierarchy, as: @polymorphic_name)
25
+ end
26
+
27
+ # Intialize a copy of self with a new / derived source table
28
+ def spawn(source)
29
+ return self.class.new(source, @target, @hierarchy, as: @polymorphic_name, parent: self)
30
+ end
31
+
32
+ def hierarchy_models
33
+ [@model] + @hierarchy.map{|m| @model.reflect_on_association(m).klass}
34
+ end
35
+
36
+ # TODO: some of these may be :through others, so this may generate redundant joins
37
+ def hierarchy_joins
38
+ @hierarchy
39
+ end
40
+
41
+ def and_conditions(conditions)
42
+ conditions.reduce{|q, cond| q.and(cond)}
43
+ end
44
+
45
+ def or_conditions(conditions)
46
+ conditions.reduce{|q, cond| q.or(cond)}
47
+ end
48
+
49
+ def filters
50
+ or_conditions(hierarchy_models.map{|model| filter(model)})
51
+ end
52
+
53
+ def filter(model)
54
+ foreign_type_column.eq(model_type(model)).
55
+ and(foreign_key_column.eq(model_key(model)))
56
+ end
57
+
58
+ # Sort order for hierarchy shadowing queries
59
+ def hierarchy_rank
60
+ Arel.sql(
61
+ "CASE `#{@source.name}`.`#{foreign_type_name}` " +
62
+ hierarchy_models.map.with_index do |model, ii|
63
+ "WHEN #{model.sanitize(model.base_class.to_s)} THEN #{ii} "
64
+ end.join +
65
+ "END"
66
+ )
67
+ end
68
+
69
+ def foreign_key_name
70
+ @polymorphic_name.foreign_key
71
+ end
72
+
73
+ def foreign_type_name
74
+ @polymorphic_name + "_type"
75
+ end
76
+
77
+ def foreign_key_column
78
+ @source[foreign_key_name]
79
+ end
80
+
81
+ def foreign_type_column
82
+ @source[foreign_type_name]
83
+ end
84
+
85
+ def model_type(model)
86
+ model.base_class.to_s
87
+ end
88
+
89
+ def model_key(model)
90
+ model.arel_table[model.primary_key]
91
+ end
92
+
93
+ # Join @model to @source only on best hierarchy matches
94
+ ### FASTER METHOD: join source to source alias on source.rank < alias.rank where alias does not exist
95
+ # This performs OK.
96
+ def join_best_rank(group_by: nil)
97
+ better_rank = spawn(@source.alias("better_hierarchy"))
98
+ @model.joins(@hierarchy).arel.
99
+ join(@source).on(filters).
100
+ join(better_rank.source, Arel::Nodes::OuterJoin).
101
+ on(
102
+ better_rank.filters.
103
+ and(better_rank.hierarchy_rank.lt(hierarchy_rank))
104
+ ).
105
+ where(better_rank.source[:id].eq(nil))
106
+ end
107
+
108
+ # # TODO: generate this dynamically based on existing columns and selects
109
+ # def best_rank_column_name
110
+ # "through_hierarchy_best_rank"
111
+ # end
112
+
113
+ # def best_rank_column
114
+ # @source[best_rank_column_name]
115
+ # end
116
+
117
+ # def best_rank
118
+ # hierarchy_rank.minimum.as(best_rank_column_name)
119
+ # end
120
+
121
+ # def best_rank_table_name
122
+ # "through_hierarchy_best_rank"
123
+ # end
124
+
125
+ # SLOW METHOD: subquery, gorup, min(priority). This performs abysmally.
126
+ # # TODO: replace model_key(@model) with target_key?
127
+ # def join_best_rank(group_by: nil)
128
+ # sub = best_rank_subquery(*group_by)
129
+ # @model.joins(@hierarchy).arel.
130
+ # join(sub.source).
131
+ # on(sub.source["model_key"].eq(model_key(@model))).
132
+ # join(@source).on(
133
+ # and_conditions([
134
+ # filters,
135
+ # hierarchy_rank.eq(sub.best_rank_column),
136
+ # *[*group_by].map{|gg| @source[gg].eq(sub.source[gg])}
137
+ # ])
138
+ # ).
139
+ # order(model_key(@model), *group_by)
140
+ # end
141
+
142
+ # # TODO: does ordering the subquery increase performance?
143
+ # # TODO: override model_key in spawn to refer to projected column
144
+ # def best_rank_subquery(*group_bys)
145
+ # @source.respond_to?(:project) or raise ThroughHierarchySourceError, "#{@source} cannot be converted into a subquery"
146
+ # group_nodes = group_bys.map{|gg|@source[gg]}
147
+ # subq = @model.joins(@hierarchy).arel.
148
+ # project(model_key(@model).as("model_key"), *group_nodes, best_rank).
149
+ # join(@source).on(filters).
150
+ # group(model_key(@model), *group_nodes).
151
+ # as(best_rank_table_name)
152
+ # spawn(subq)
153
+ # end
154
+
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,76 @@
1
+ module ThroughHierarchy
2
+ module Hierarchicals
3
+ class Instance < Hierarchical
4
+ def set_target(target)
5
+ @target = target
6
+ @instance = @target
7
+ @model = @target.class
8
+ end
9
+
10
+ def hierarchy_instances
11
+ [@instance] + @hierarchy.map{|m| @instance.association(m).load_target}
12
+ end
13
+
14
+ def filters
15
+ or_conditions(hierarchy_instances.map{|instance| filter(instance)})
16
+ end
17
+
18
+ def filter(instance)
19
+ foreign_type_column.eq(instance_type(instance)).
20
+ and(foreign_key_column.eq(instance_key(instance)))
21
+ end
22
+
23
+ def instance_type(instance)
24
+ instance.class.base_class.to_s
25
+ end
26
+
27
+ def instance_key(instance)
28
+ instance.attributes[@model.primary_key]
29
+ end
30
+
31
+ def best_rank_column_name
32
+ "through_hierarchy_best_rank"
33
+ end
34
+
35
+ def best_rank_column
36
+ @source[best_rank_column_name]
37
+ end
38
+
39
+ def best_rank
40
+ hierarchy_rank.minimum.as(best_rank_column_name)
41
+ end
42
+
43
+ def best_rank_table_name
44
+ "through_hierarchy_best_rank"
45
+ end
46
+
47
+ # Select only sources with best hierarchy rank for target instance
48
+ # Uses subquery grouped by specified column to compute best rank
49
+ # TODO: experiment with the model-style double-join method instead
50
+ def select_best_rank(group_by:)
51
+ sub = best_rank_subquery(group_by)
52
+ @source.
53
+ join(sub.source).
54
+ on(
55
+ hierarchy_rank.eq(sub.best_rank_column).
56
+ and(@source[group_by].eq(sub.source[group_by]))
57
+ ).
58
+ order(@source[group_by])
59
+ end
60
+
61
+ # Return a new Hierarchical::Instance representing a subquery that contains
62
+ # only best-rank sources.
63
+ def best_rank_subquery(group_by)
64
+ @source.respond_to?(:project) or raise ThroughHierarchySourceError, "#{@source} cannot be converted into a subquery"
65
+ subq = source.
66
+ project(foreign_type_column, foreign_key_column, group_by, best_rank).
67
+ where(filters).
68
+ group(source[group_by]).
69
+ as(best_rank_table_name)
70
+
71
+ spawn(subq)
72
+ end
73
+
74
+ end
75
+ end
76
+ end
@@ -4,7 +4,7 @@ module ThroughHierarchy
4
4
  @klass = klass
5
5
  @members = members
6
6
 
7
- validate_members
7
+ validate_hierarchy
8
8
  end
9
9
 
10
10
  def has_one(name, scope = nil, **options)
@@ -15,15 +15,19 @@ module ThroughHierarchy
15
15
 
16
16
  def has_many(name, scope = nil, **options)
17
17
  options.merge!(scope: scope) if scope.present?
18
- assoc = ::ThroughHierarchy::Associations::HasMany.new(name, @klass, @members, options)
18
+ if options.key?(:uniq)
19
+ assoc = ::ThroughHierarchy::Associations::HasUniq.new(name, @klass, @members, options)
20
+ else
21
+ assoc = ::ThroughHierarchy::Associations::HasMany.new(name, @klass, @members, options)
22
+ end
19
23
  ::ThroughHierarchy::Builder.new(@klass).add_association(name, assoc)
20
24
  end
21
25
 
22
26
  private
23
27
 
24
- def validate_members
28
+ def validate_hierarchy
25
29
  @members.is_a?(::Array) or ::Kernel.raise ::ThroughHierarchy::ThroughHierarchyDefinitionError, "Hierarchy members: expected: Array, got: #{@members.class}"
26
- @members.all?{|member| @klass.reflect_on_association(member).present?} or ::Kernel.raise ::ThroughHierarchy::ThroughHierarchyDefinitionError, "No association named #{member} was found. Perhaps you misspelled it?"
30
+ @members.all?{|member| @klass.reflect_on_association(member).present? or ::Kernel.raise ::ThroughHierarchy::ThroughHierarchyDefinitionError, "No association named #{member} was found. Perhaps you misspelled it?"}
27
31
  end
28
32
  end
29
33
  end
@@ -4,6 +4,9 @@ require 'through_hierarchy/builder.rb'
4
4
  require 'through_hierarchy/associations/association.rb'
5
5
  require 'through_hierarchy/associations/has_one.rb'
6
6
  require 'through_hierarchy/associations/has_many.rb'
7
+ require 'through_hierarchy/associations/has_uniq.rb'
7
8
  require 'through_hierarchy/exceptions.rb'
9
+ require 'through_hierarchy/hierarchicals/hierarchical.rb'
10
+ require 'through_hierarchy/hierarchicals/instance.rb'
8
11
 
9
12
  ActiveRecord::Base.include(ThroughHierarchy::Base)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: through_hierarchy
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.2
4
+ version: 0.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Schwartz
@@ -20,9 +20,12 @@ files:
20
20
  - lib/through_hierarchy/associations/association.rb
21
21
  - lib/through_hierarchy/associations/has_many.rb
22
22
  - lib/through_hierarchy/associations/has_one.rb
23
+ - lib/through_hierarchy/associations/has_uniq.rb
23
24
  - lib/through_hierarchy/base.rb
24
25
  - lib/through_hierarchy/builder.rb
25
26
  - lib/through_hierarchy/exceptions.rb
27
+ - lib/through_hierarchy/hierarchicals/hierarchical.rb
28
+ - lib/through_hierarchy/hierarchicals/instance.rb
26
29
  - lib/through_hierarchy/hierarchy.rb
27
30
  homepage: https://github.com/ozydingo/through_hierarchy
28
31
  licenses: