through_hierarchy 0.0.2 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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: