refine-rails 2.9.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (141) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +413 -0
  3. data/Rakefile +8 -0
  4. data/app/assets/config/refine_rails_manifest.js +0 -0
  5. data/app/assets/javascripts/refine-stimulus.esm.js +2 -0
  6. data/app/assets/javascripts/refine-stimulus.esm.js.map +1 -0
  7. data/app/assets/javascripts/refine-stimulus.js +2 -0
  8. data/app/assets/javascripts/refine-stimulus.js.map +1 -0
  9. data/app/assets/javascripts/refine-stimulus.modern.js +2 -0
  10. data/app/assets/javascripts/refine-stimulus.modern.js.map +1 -0
  11. data/app/assets/javascripts/refine-stimulus.umd.js +2 -0
  12. data/app/assets/javascripts/refine-stimulus.umd.js.map +1 -0
  13. data/app/assets/stylesheets/index.css +1873 -0
  14. data/app/assets/stylesheets/index.tailwind.css +1035 -0
  15. data/app/controllers/refine/blueprints_controller.rb +80 -0
  16. data/app/controllers/refine/filter_application_controller.rb +29 -0
  17. data/app/controllers/refine/inline/criteria_controller.rb +161 -0
  18. data/app/controllers/refine/inline/stored_filters_controller.rb +84 -0
  19. data/app/controllers/refine/stored_filters_controller.rb +69 -0
  20. data/app/javascript/controllers/index.js +66 -0
  21. data/app/javascript/controllers/refine/add-controller.js +42 -0
  22. data/app/javascript/controllers/refine/criterion-form-controller.js +31 -0
  23. data/app/javascript/controllers/refine/date-controller.js +113 -0
  24. data/app/javascript/controllers/refine/defaults-controller.js +32 -0
  25. data/app/javascript/controllers/refine/delete-controller.js +13 -0
  26. data/app/javascript/controllers/refine/filter-pills-controller.js +63 -0
  27. data/app/javascript/controllers/refine/form-controller.js +51 -0
  28. data/app/javascript/controllers/refine/inline-conditions-controller.js +33 -0
  29. data/app/javascript/controllers/refine/popup-controller.js +46 -0
  30. data/app/javascript/controllers/refine/search-filter-controller.js +50 -0
  31. data/app/javascript/controllers/refine/server-refresh-controller.js +43 -0
  32. data/app/javascript/controllers/refine/state-controller.js +220 -0
  33. data/app/javascript/controllers/refine/stored-filter-controller.js +23 -0
  34. data/app/javascript/controllers/refine/submit-form-controller.js +8 -0
  35. data/app/javascript/controllers/refine/toggle-controller.js +12 -0
  36. data/app/javascript/controllers/refine/turbo-stream-form-controller.js +24 -0
  37. data/app/javascript/controllers/refine/turbo-stream-link-controller.js +24 -0
  38. data/app/javascript/controllers/refine/update-controller.js +86 -0
  39. data/app/javascript/index.js +1 -0
  40. data/app/javascript/refine/helpers/index.js +77 -0
  41. data/app/models/refine/blueprints/blueprint.rb +58 -0
  42. data/app/models/refine/blueprints/blueprint_example.json +25 -0
  43. data/app/models/refine/conditions/boolean_condition.rb +112 -0
  44. data/app/models/refine/conditions/clause.rb +38 -0
  45. data/app/models/refine/conditions/clauses.rb +38 -0
  46. data/app/models/refine/conditions/condition.rb +285 -0
  47. data/app/models/refine/conditions/condition_error.rb +1 -0
  48. data/app/models/refine/conditions/date_condition.rb +464 -0
  49. data/app/models/refine/conditions/date_with_time_condition.rb +8 -0
  50. data/app/models/refine/conditions/errors/condition_clause_error.rb +7 -0
  51. data/app/models/refine/conditions/errors/criteria_limit_exceeded_error.rb +2 -0
  52. data/app/models/refine/conditions/errors/option_error.rb +2 -0
  53. data/app/models/refine/conditions/errors/relationship_error.rb +1 -0
  54. data/app/models/refine/conditions/filter_condition.rb +93 -0
  55. data/app/models/refine/conditions/has_clauses.rb +117 -0
  56. data/app/models/refine/conditions/has_meta.rb +10 -0
  57. data/app/models/refine/conditions/has_refinements.rb +156 -0
  58. data/app/models/refine/conditions/numeric_condition.rb +224 -0
  59. data/app/models/refine/conditions/option_condition.rb +260 -0
  60. data/app/models/refine/conditions/text_condition.rb +152 -0
  61. data/app/models/refine/conditions/uses_attributes.rb +168 -0
  62. data/app/models/refine/filter.rb +302 -0
  63. data/app/models/refine/filters/blueprint_editor.rb +102 -0
  64. data/app/models/refine/filters/builder.rb +59 -0
  65. data/app/models/refine/filters/criterion.rb +87 -0
  66. data/app/models/refine/filters/query.rb +82 -0
  67. data/app/models/refine/inline/criteria/input.rb +50 -0
  68. data/app/models/refine/inline/criteria/numeric_refinement.rb +13 -0
  69. data/app/models/refine/inline/criteria/option.rb +2 -0
  70. data/app/models/refine/inline/criterion.rb +141 -0
  71. data/app/models/refine/invalid_filter_error.rb +8 -0
  72. data/app/models/refine/stabilize.rb +29 -0
  73. data/app/models/refine/stabilizers/database_stabilizer.rb +21 -0
  74. data/app/models/refine/stabilizers/errors/url_stabilizer_error.rb +2 -0
  75. data/app/models/refine/stabilizers/url_encoded_stabilizer.rb +21 -0
  76. data/app/models/refine/stored_filter.rb +14 -0
  77. data/app/models/refine/tracks_pending_relationship_subqueries.rb +196 -0
  78. data/app/views/_filter_builder_dropdown.html.erb +63 -0
  79. data/app/views/_filter_pills.html.erb +40 -0
  80. data/app/views/_loading.html.erb +32 -0
  81. data/app/views/refine/blueprints/_add_and.html.erb +25 -0
  82. data/app/views/refine/blueprints/_add_group.html.erb +24 -0
  83. data/app/views/refine/blueprints/_clause_select.html.erb +24 -0
  84. data/app/views/refine/blueprints/_condition_select.html.erb +53 -0
  85. data/app/views/refine/blueprints/_criterion.html.erb +41 -0
  86. data/app/views/refine/blueprints/_criterion_errors.html.erb +7 -0
  87. data/app/views/refine/blueprints/_delete_criterion.html.erb +11 -0
  88. data/app/views/refine/blueprints/_group.html.erb +13 -0
  89. data/app/views/refine/blueprints/_query.html.erb +34 -0
  90. data/app/views/refine/blueprints/_stored_filters.html.erb +23 -0
  91. data/app/views/refine/blueprints/clauses/_date_condition.html.erb +80 -0
  92. data/app/views/refine/blueprints/clauses/_date_picker.html.erb +26 -0
  93. data/app/views/refine/blueprints/clauses/_filter_condition.html.erb +36 -0
  94. data/app/views/refine/blueprints/clauses/_numeric_condition.html.erb +35 -0
  95. data/app/views/refine/blueprints/clauses/_option_condition.html.erb +37 -0
  96. data/app/views/refine/blueprints/clauses/_text_condition.html.erb +13 -0
  97. data/app/views/refine/blueprints/create.turbo_stream.erb +22 -0
  98. data/app/views/refine/blueprints/new.html.erb +7 -0
  99. data/app/views/refine/blueprints/show.html.erb +4 -0
  100. data/app/views/refine/blueprints/show.turbo_stream.erb +22 -0
  101. data/app/views/refine/inline/criteria/_form_fields.html.erb +62 -0
  102. data/app/views/refine/inline/criteria/create.turbo_stream.erb +19 -0
  103. data/app/views/refine/inline/criteria/edit.turbo_stream.erb +26 -0
  104. data/app/views/refine/inline/criteria/index.html.erb +64 -0
  105. data/app/views/refine/inline/criteria/new.turbo_stream.erb +24 -0
  106. data/app/views/refine/inline/filters/_add_first_condition_button.html.erb +19 -0
  107. data/app/views/refine/inline/filters/_and_button.html.erb +26 -0
  108. data/app/views/refine/inline/filters/_criterion.html.erb +23 -0
  109. data/app/views/refine/inline/filters/_group.html.erb +13 -0
  110. data/app/views/refine/inline/filters/_load_button.html.erb +15 -0
  111. data/app/views/refine/inline/filters/_or_button.html.erb +26 -0
  112. data/app/views/refine/inline/filters/_popup.html.erb +26 -0
  113. data/app/views/refine/inline/filters/_save_button.html.erb +15 -0
  114. data/app/views/refine/inline/filters/_show.html.erb +40 -0
  115. data/app/views/refine/inline/inputs/_date_condition.html.erb +7 -0
  116. data/app/views/refine/inline/inputs/_date_condition_days.html.erb +18 -0
  117. data/app/views/refine/inline/inputs/_date_condition_range.html.erb +22 -0
  118. data/app/views/refine/inline/inputs/_date_condition_single.html.erb +9 -0
  119. data/app/views/refine/inline/inputs/_date_picker.html.erb +20 -0
  120. data/app/views/refine/inline/inputs/_numeric_condition.html.erb +23 -0
  121. data/app/views/refine/inline/inputs/_option_condition.html.erb +14 -0
  122. data/app/views/refine/inline/inputs/_text_condition.html.erb +8 -0
  123. data/app/views/refine/inline/stored_filters/find.turbo_stream.erb +19 -0
  124. data/app/views/refine/inline/stored_filters/index.html.erb +28 -0
  125. data/app/views/refine/inline/stored_filters/new.turbo_stream.erb +47 -0
  126. data/app/views/refine/stored_filters/create.turbo_stream.erb +2 -0
  127. data/app/views/refine/stored_filters/find.turbo_stream.erb +5 -0
  128. data/app/views/refine/stored_filters/index.html.erb +39 -0
  129. data/app/views/refine/stored_filters/new.html.erb +29 -0
  130. data/app/views/refine/stored_filters/show.html.erb +1 -0
  131. data/config/locales/en/dates.en.yml +29 -0
  132. data/config/locales/en/en.yml +20 -0
  133. data/config/locales/en/refine.en.yml +187 -0
  134. data/config/routes.rb +17 -0
  135. data/lib/generators/filter/filter_generator.rb +27 -0
  136. data/lib/generators/filter/templates/filter.rb.erb +20 -0
  137. data/lib/refine/rails/engine.rb +15 -0
  138. data/lib/refine/rails/version.rb +5 -0
  139. data/lib/refine/rails.rb +38 -0
  140. data/lib/tasks/refine/rails_tasks.rake +13 -0
  141. metadata +202 -0
@@ -0,0 +1,152 @@
1
+ module Refine::Conditions
2
+ class TextCondition < Condition
3
+ include HasClauses
4
+
5
+ CLAUSE_EQUALS = Clauses::EQUALS
6
+ CLAUSE_DOESNT_EQUAL = Clauses::DOESNT_EQUAL
7
+
8
+ CLAUSE_STARTS_WITH = Clauses::STARTS_WITH
9
+ CLAUSE_ENDS_WITH = Clauses::ENDS_WITH
10
+ CLAUSE_DOESNT_START_WITH = Clauses::DOESNT_START_WITH
11
+ CLAUSE_DOESNT_END_WITH = Clauses::DOESNT_END_WITH
12
+
13
+ CLAUSE_CONTAINS = Clauses::CONTAINS
14
+ CLAUSE_DOESNT_CONTAIN = Clauses::DOESNT_CONTAIN
15
+
16
+ CLAUSE_SET = Clauses::SET
17
+
18
+ CLAUSE_NOT_SET = Clauses::NOT_SET
19
+
20
+ I18N_PREFIX = "refine.refine_blueprints.text_condition."
21
+
22
+ def component
23
+ "text-condition"
24
+ end
25
+
26
+ def human_readable(input)
27
+ current_clause = get_clause_by_id(input[:clause])
28
+ if input[:clause].in? [CLAUSE_SET, CLAUSE_NOT_SET]
29
+ "#{display} #{current_clause.display}"
30
+ else
31
+ "#{display} #{current_clause.display} #{input[:value]}"
32
+ end
33
+ end
34
+
35
+ def human_readable_value(input)
36
+ current_clause = get_clause_by_id(input[:clause])
37
+ if input[:clause].in? [CLAUSE_SET, CLAUSE_NOT_SET]
38
+ ""
39
+ else
40
+ input[:value]
41
+ end
42
+ end
43
+
44
+ def clauses
45
+ [
46
+ Clause.new(CLAUSE_EQUALS, I18n.t("#{I18N_PREFIX}is"))
47
+ .requires_inputs(["value"]),
48
+
49
+ Clause.new(CLAUSE_DOESNT_EQUAL, I18n.t("#{I18N_PREFIX}is_not"))
50
+ .requires_inputs(["value"]),
51
+
52
+ Clause.new(CLAUSE_STARTS_WITH, I18n.t("#{I18N_PREFIX}starts_with"))
53
+ .requires_inputs(["value"]),
54
+
55
+ Clause.new(CLAUSE_ENDS_WITH, I18n.t("#{I18N_PREFIX}ends_with"))
56
+ .requires_inputs(["value"]),
57
+
58
+ Clause.new(CLAUSE_DOESNT_START_WITH, I18n.t("#{I18N_PREFIX}does_not_start_with"))
59
+ .requires_inputs(["value"]),
60
+
61
+ Clause.new(CLAUSE_DOESNT_END_WITH, I18n.t("#{I18N_PREFIX}does_not_end_with"))
62
+ .requires_inputs(["value"]),
63
+
64
+ Clause.new(CLAUSE_CONTAINS, I18n.t("#{I18N_PREFIX}contains"))
65
+ .requires_inputs(["value"]),
66
+
67
+ Clause.new(CLAUSE_DOESNT_CONTAIN, I18n.t("#{I18N_PREFIX}does_not_contain"))
68
+ .requires_inputs(["value"]),
69
+
70
+ Clause.new(CLAUSE_SET, I18n.t("#{I18N_PREFIX}is_set")),
71
+
72
+ Clause.new(CLAUSE_NOT_SET, I18n.t("#{I18N_PREFIX}is_not_set"))
73
+ ]
74
+ end
75
+
76
+ def apply_condition(input, table, _inverse_clause)
77
+ value = input[:value]
78
+
79
+ case clause
80
+ when CLAUSE_EQUALS
81
+ apply_clause_equals(value, table)
82
+
83
+ when CLAUSE_DOESNT_EQUAL
84
+ apply_clause_doesnt_equal(value, table)
85
+
86
+ when CLAUSE_STARTS_WITH
87
+ apply_clause_starts_with(value, table)
88
+
89
+ when CLAUSE_ENDS_WITH
90
+ apply_clause_ends_with(value, table)
91
+
92
+ when CLAUSE_DOESNT_START_WITH
93
+ apply_clause_doesnt_start_with(value, table)
94
+
95
+ when CLAUSE_DOESNT_END_WITH
96
+ apply_clause_doesnt_end_with(value, table)
97
+
98
+ when CLAUSE_CONTAINS
99
+ apply_clause_contains(value, table)
100
+
101
+ when CLAUSE_DOESNT_CONTAIN
102
+ apply_clause_doesnt_contain(value, table)
103
+
104
+ when CLAUSE_SET
105
+ apply_clause_set(value, table)
106
+
107
+ when CLAUSE_NOT_SET
108
+ apply_clause_not_set(value, table)
109
+ end
110
+ end
111
+
112
+ def apply_clause_equals(value, table)
113
+ table.grouping(arel_attribute(table).eq(value))
114
+ end
115
+
116
+ def apply_clause_doesnt_equal(value, table)
117
+ table.grouping(arel_attribute(table).not_eq(value).or(arel_attribute(table).eq(nil)))
118
+ end
119
+
120
+ def apply_clause_starts_with(value, table)
121
+ table.grouping(arel_attribute(table).matches("#{value}%"))
122
+ end
123
+
124
+ def apply_clause_ends_with(value, table)
125
+ table.grouping(arel_attribute(table).matches("%#{value}"))
126
+ end
127
+
128
+ def apply_clause_contains(value, table)
129
+ table.grouping(arel_attribute(table).matches("%#{value}%"))
130
+ end
131
+
132
+ def apply_clause_doesnt_contain(value, table)
133
+ table.grouping(arel_attribute(table).does_not_match("%#{value}%").or(arel_attribute(table).eq(nil)))
134
+ end
135
+
136
+ def apply_clause_set(_, table)
137
+ table.grouping(arel_attribute(table).not_eq_all([nil, ""]))
138
+ end
139
+
140
+ def apply_clause_not_set(_, table)
141
+ table.grouping(arel_attribute(table).eq_any([nil, ""]))
142
+ end
143
+
144
+ def apply_clause_doesnt_start_with(value, table)
145
+ table.grouping(arel_attribute(table).does_not_match("#{value}%"))
146
+ end
147
+
148
+ def apply_clause_doesnt_end_with(value, table)
149
+ table.grouping(arel_attribute(table).does_not_match("%#{value}"))
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,168 @@
1
+ module Refine::Conditions
2
+ module UsesAttributes
3
+
4
+ def with_attribute(value)
5
+ @attribute = value
6
+ self
7
+ end
8
+
9
+ def apply_relationship_attribute(input:, query:)
10
+ # Split on first .
11
+ decompose_attribute = @attribute.split(".", 2)
12
+ # Attribute now is the back half of the initial attribute
13
+ @attribute = decompose_attribute[1]
14
+
15
+ if !@attribute.include?(".")
16
+ # No more .s, deepest level
17
+ @on_deepest_relationship = true
18
+ end
19
+ # Relation to be handled
20
+ relation = decompose_attribute[0]
21
+
22
+ # Get the Reflection object which defines the relationship between query and relation
23
+ # First iteration pull relationship using base query which responds to model.
24
+ instance = if query.respond_to? :model
25
+ query.model.reflect_on_association(relation.to_sym)
26
+ else
27
+ # When query is sent in as subquery (recursive) the query object is the model class pulled from the
28
+ # previous instance value
29
+ query.reflect_on_association(relation.to_sym)
30
+ end
31
+
32
+ unless instance
33
+ raise "Relationship does not exist for #{relation}."
34
+ end
35
+
36
+ filter.set_pending_relationship(relation, instance)
37
+
38
+ # If the current condition is a refinement (filter refinement) set collapsible to true
39
+ if is_refinement
40
+ filter.allow_pending_relationship_to_collapse
41
+ end
42
+
43
+ if can_use_where_in_relationship_subquery?(instance)
44
+ create_pending_wherein_subquery(input: input, relation: relation, instance: instance, query: query)
45
+ else
46
+ create_pending_has_many_through_subquery(input: input, relation: relation, instance: instance, query: query)
47
+ end
48
+
49
+ filter.release_pending_relationship
50
+ # We want the method to return nil for relationship attributes
51
+ # The purpose of this method is to populate pending relationship subqueries
52
+ nil
53
+ end
54
+
55
+ def key_1(instance)
56
+ # Foreign key on belongs to, primary key on HasMany
57
+ if instance.is_a? ActiveRecord::Reflection::BelongsToReflection
58
+ instance.foreign_key.to_sym
59
+ else
60
+ instance.active_record_primary_key.to_sym
61
+ end
62
+ end
63
+
64
+ def key_2(instance)
65
+ if instance.is_a? ActiveRecord::Reflection::BelongsToReflection
66
+ instance.active_record_primary_key.to_sym
67
+ else
68
+ instance.foreign_key.to_sym
69
+ end
70
+ end
71
+
72
+ def create_pending_wherein_subquery(input:, relation:, instance:, query:)
73
+ # This method builds out the linking keys between the provided query model and the relation
74
+ # and saves it to pending relationship subqueries
75
+ # Class of the relation as held in the AR::Relation object
76
+ relation_class = instance.klass
77
+
78
+ # Pull what's already in the tracker at this depth if already traversed
79
+ subquery = filter.get_pending_relationship_subquery || relation_class.select([key_2(instance)]).arel
80
+
81
+ # Primary/secondary keys keep track of how to link tables
82
+ # If depth has been added (i.e. filter.pending_relationship_subquery_depth = [:btt_user, :btt_notes])
83
+ # This will add the [:children] key to the pending_relationship_subqueries tracker under the parent key [:btt_user]
84
+ filter.add_pending_relationship_subquery(subquery: subquery, primary_key: key_1(instance), secondary_key: key_2(instance))
85
+
86
+ # Apply the condition. If a nested relationship, this apply is adding the children key (with values) to the pending_relationship_subqueries tracker
87
+ # due to the recursive nature of the apply method. This is critical because it get is then "rolled up" in release_pending_relationship
88
+ node = apply(input, relation_class.arel_table, relation_class, false)
89
+
90
+ # If node is an AREL::SELECT manager we are allowing the apply condition to return a fully formed subquery - we replace
91
+ # the linking keys in the tracker with the fully formed select query
92
+ # Has not been tested more than one level deep
93
+ if node.is_a? Arel::SelectManager
94
+ filter.add_pending_relationship_subquery(subquery: node, primary_key: key_1(instance), secondary_key: key_2(instance))
95
+ elsif node
96
+ # This modifies subquery *in* the pending_relationship_subqueries tracker.
97
+ subquery.where(node)
98
+ end
99
+ end
100
+
101
+ def group(nodes)
102
+ Arel::Nodes::Grouping.new(nodes)
103
+ end
104
+
105
+ # Determine if the clause should be flipped. For example, "not_eq" => "eq". Must also change "in" to "not in" upstream
106
+ # @param [Object] instance
107
+ # @param [String] clause The join clause (example: `eq` or `not_eq`)
108
+ # @return [Boolean]
109
+ def should_inverse_clause?(instance, clause)
110
+ is_through_reflection = instance.is_a?(ActiveRecord::Reflection::ThroughReflection)
111
+ is_inverse_clause_flippable = Clauses::FLIPPABLE.include?(clause)
112
+
113
+ is_through_reflection && is_inverse_clause_flippable
114
+ end
115
+
116
+ def create_pending_has_many_through_subquery(input:, relation:, instance:, query:)
117
+ # In a has_many relationship the negative has to be flipped to positive.
118
+ inverse_clause = should_inverse_clause?(instance, input[:clause])
119
+ # Ex: A country has many posts through hmtt_users.
120
+ # Use AR to properly join the relation to the base query provided
121
+ # Convert to AREL to use with nodes
122
+ subquery_path = query.model.select(key_1(instance)).joins(relation.to_sym).arel
123
+ relation_table_being_queried = instance.klass.arel_table
124
+
125
+ relation_class = instance.klass
126
+
127
+ node_to_apply = apply(input, relation_table_being_queried, relation_class, inverse_clause)
128
+
129
+ complete_subquery = subquery_path.where(node_to_apply)
130
+ subquery = filter.get_pending_relationship_subquery || complete_subquery
131
+ filter.add_pending_relationship_subquery(subquery: subquery, primary_key: key_1(instance), secondary_key: nil, inverse_clause: inverse_clause)
132
+ end
133
+
134
+ def can_use_where_in_relationship_subquery?(instance)
135
+ # Where in only works for belongs to, has one, or has many
136
+ (instance.is_a? ActiveRecord::Reflection::BelongsToReflection) || (instance.is_a? ActiveRecord::Reflection::HasManyReflection) || (instance.is_a? ActiveRecord::Reflection::HasOneReflection)
137
+ end
138
+
139
+ def is_relationship_attribute?
140
+ # TODO: Allow user to decide attribute is not a relationship
141
+ # If we are on the deepest relationship, it's no longer a relationship attribute
142
+ return false if @on_deepest_relationship
143
+ # If the attribute includes a ., it's a relationship attribute
144
+ @attribute.include?(".")
145
+ end
146
+
147
+ def raw_attribute(attribute)
148
+ @attribute = Arel.sql(attribute)
149
+
150
+ self
151
+ end
152
+
153
+ # TODO Examine the existing relationships and suggest model names if not instance is found
154
+ # def get_relationships(query)
155
+ # if query.respond_to? :model
156
+ # associations = query.model.reflect_on_all_associations
157
+ # else
158
+ # associations = query.reflect_on_all_associations
159
+ # end
160
+ # associations.map{|entry| puts entry.class, entry.foreign_key, entry.klass }
161
+ # differences=[]
162
+ # associations.each do association
163
+ # differences << String::Similarity.levenshtein_distance(relation, association )
164
+ # end
165
+ # differences
166
+ # end
167
+ end
168
+ end
@@ -0,0 +1,302 @@
1
+ module Refine
2
+ class Filter
3
+ include ActiveModel::Validations
4
+ include ActiveModel::Callbacks
5
+ include TracksPendingRelationshipSubqueries
6
+ include Stabilize
7
+ # This validation structure sents `initial_query` as the method to validate against
8
+ define_model_callbacks :initialize, only: [:after]
9
+ after_initialize :valid?
10
+
11
+ cattr_accessor :default_stabilizer, default: nil, instance_accessor: false
12
+ cattr_accessor :criteria_limit, default: 5, instance_accessor: true
13
+
14
+ attr_reader :blueprint, :initial_query
15
+
16
+ # Give each Filter subclass its own default_condition_id,
17
+ # that is also also readable from instances
18
+ #
19
+ # class UserFilter
20
+ # self.default_condition_id = "email"
21
+ # # ...
22
+ # end
23
+ #
24
+ class << self
25
+ attr_accessor :default_condition_id
26
+ end
27
+ delegate :default_condition_id, to: :class
28
+
29
+ def initialize(blueprint = nil, query_scope = nil)
30
+ run_callbacks :initialize do
31
+ # If using this in test mode, `blueprint` will be an instance of
32
+ # `Blueprint` and the value must be extracted
33
+ if blueprint.is_a? Blueprints::Blueprint
34
+ blueprint = blueprint.to_array
35
+ end
36
+ @initial_query = query_scope
37
+ @blueprint = blueprint
38
+ @relation = initial_query
39
+ @immediately_commit_pending_relationship_subqueries = false
40
+ @@default_stabilizer = Refine::Stabilizers::UrlEncodedStabilizer
41
+ end
42
+ end
43
+
44
+ # DEPRECATED use Refine::Filters::Criterion#human_readable instead
45
+ def human_readable_criterions
46
+ output = []
47
+ if blueprint.present?
48
+ blueprint.each do |criterion|
49
+ if criterion[:type] == "conjunction"
50
+ output << criterion[:word]
51
+ else
52
+ output << get_condition_for_criterion(criterion).human_readable(criterion[:input])
53
+ end
54
+ end
55
+ end
56
+ output
57
+ end
58
+
59
+ def automatically_stabilize?
60
+ true
61
+ end
62
+
63
+ # e.g. ContactsFilter -> Contact
64
+ def model
65
+ initial_query&.model || self.class.to_s.gsub(/Filter$/, "").singularize.constantize
66
+ end
67
+
68
+ def table
69
+ model.arel_table
70
+ end
71
+
72
+ def valid_query?
73
+ get_query
74
+ errors.none?
75
+ end
76
+
77
+ def get_query
78
+ raise "Initial query must exist" if initial_query.nil?
79
+ if blueprint.present?
80
+ @relation.where(group(make_sub_query(blueprint)))
81
+ else
82
+ @relation
83
+ end
84
+ end
85
+
86
+ def get_query!
87
+ result = get_query
88
+ raise Refine::InvalidFilterError.new(filter: self) unless errors.none?
89
+ result
90
+ end
91
+
92
+ def add_nodes_to_query(subquery:, nodes:, query_method:)
93
+ # Apply existing nodes to existing subquery
94
+ if subquery.present? && nodes.present?
95
+ subquery = if query_method == "and"
96
+ # Apply the nodes using the AREL AND method
97
+ subquery.send(query_method, group(nodes))
98
+ else
99
+ # Override the AREL OR method in order to remove the automatic parens
100
+ Arel::Nodes::Or.new(subquery, group(nodes))
101
+ end
102
+ # No nodes returned, do nothing
103
+ elsif subquery.present? && nodes.blank?
104
+ subquery
105
+ # Subquery has not yet been initialized, initialize with new nodes - must use !nil? here, present/exists/blank etc don't
106
+ # account for AR::Relation object.
107
+ elsif subquery.blank? && !nodes.nil?
108
+ subquery = group(nodes)
109
+ end
110
+ subquery
111
+ end
112
+
113
+ def make_sub_query(modified_blueprint, depth = 0, subquery = nil)
114
+ # Need index control to directly skip indicies in fast forward (recursive call)
115
+ index = 0
116
+ while index < modified_blueprint.length
117
+ criterion = modified_blueprint[index]
118
+ # Decreasing depth, pass control back to caller
119
+ break if criterion[:depth] < depth
120
+
121
+ # If it's a conjunction, the next condition will handle it.
122
+ if criterion[:type] == "conjunction"
123
+ index += 1
124
+ next
125
+ end
126
+
127
+ # Check the word on the previous blueprint method. If it is not 'and'....?
128
+ query_method = if index == 0
129
+ "and"
130
+ else
131
+ modified_blueprint[index - 1][:word] == "and" ? "and" : "or"
132
+ end
133
+
134
+ # If the new depth is deeper than our current depth, that means we're
135
+ # starting a new group. We'll recursively call this method again
136
+ # with a subset of the blueprint.
137
+ if criterion[:depth] > depth
138
+ # Modify the array to send in the elements not yet handled (depth>current depth)
139
+ new_depth_array = modified_blueprint[index..-1]
140
+
141
+ # Return the nodes in () for elements on the same depth
142
+ recursive_nodes = make_sub_query(new_depth_array, depth + 1)
143
+
144
+ # Add the recursive subquery nodes to the existing query and modify the query
145
+ subquery = add_nodes_to_query(subquery: subquery, nodes: recursive_nodes, query_method: query_method)
146
+
147
+ # Skip indexes handled by recursive call
148
+ index = fast_forward(index, modified_blueprint, depth) + 1
149
+ next
150
+ end
151
+ # If there are any ORs at this depth, commit subqueries
152
+ @immediately_commit_pending_relationship_subqueries = if modified_blueprint.select { |item| (item[:type] == "conjunction" && item[:word] == "or" && item[:depth] == depth) }.present?
153
+ true
154
+ else
155
+ false
156
+ end
157
+
158
+ # If it is a relationship attribute apply_condition will call apply_relationship_attribute which will set up the pending relationship
159
+ # subquery but will not return a value.
160
+ # apply condition is NOT idempotent, hence the placeholder var
161
+ nodes_to_apply = apply_condition(criterion)
162
+ # If an error has been added to the errors array from apply_condition, do not continue execution at this level
163
+ if errors.any?
164
+ index += 1
165
+ next
166
+ end
167
+
168
+ subquery = add_nodes_to_query(subquery: subquery, nodes: nodes_to_apply, query_method: query_method)
169
+
170
+ if @immediately_commit_pending_relationship_subqueries.present?
171
+ committed_nodes_from_pending = commit_pending_relationship_subqueries
172
+ subquery = add_nodes_to_query(subquery: subquery, nodes: committed_nodes_from_pending, query_method: query_method)
173
+ end
174
+
175
+ index += 1
176
+ end
177
+
178
+ unless errors.any?
179
+ final_depth_nodes = commit_pending_relationship_subqueries
180
+ # Add nodes to existing query and return existing query
181
+ add_nodes_to_query(subquery: subquery, nodes: final_depth_nodes, query_method: query_method)
182
+ end
183
+ end
184
+
185
+ def fast_forward(index, modified_blueprint, depth)
186
+ fast_forward_index = (index..modified_blueprint.length - 1).each do |cursor|
187
+ break cursor if modified_blueprint[cursor][:depth] <= depth
188
+ end
189
+ # TODO refactor for clarity. If I break early from the iterator I have an int. If not,
190
+ # default to modfied_blueprint.length -1 as the correct value
191
+ (fast_forward_index.is_a? Integer) ? fast_forward_index : modified_blueprint.length - 1
192
+ end
193
+
194
+ def group(nodes)
195
+ Arel::Nodes::Grouping.new(nodes)
196
+ end
197
+
198
+ def apply_condition(criterion)
199
+ begin
200
+ get_condition_for_criterion(criterion)&.apply(criterion[:input], table, initial_query)
201
+ rescue Refine::Conditions::Errors::ConditionClauseError => e
202
+ e.errors.each do |error|
203
+ errors.add(:base, error.full_message, criterion_uid: criterion[:uid])
204
+ end
205
+ end
206
+ end
207
+
208
+ def get_condition_for_criterion(criterion)
209
+ # Returns the object that matches the condition. Adds errors if not found.
210
+ # This checks the id on the condition such as text_test
211
+ returned_object = conditions.find { |condition| condition.id == criterion[:condition_id] }
212
+ if returned_object.nil?
213
+ errors.add(:filter, "The condition ID #{criterion[:condition_id]} was not found")
214
+ else
215
+ # Set filter variable on condition
216
+ instantiate_condition(returned_object)
217
+ end
218
+ # Must duplicate the condition so nested attributes don't bleed into one another
219
+ returned_object.dup
220
+ end
221
+
222
+ def configuration
223
+ {
224
+ type: "Refine",
225
+ class_name: self.class.name,
226
+ blueprint: @blueprint,
227
+ conditions: conditions_to_array,
228
+ stable_id: to_optional_stable_id
229
+ }
230
+ end
231
+
232
+ def conditions_to_array
233
+ return nil unless conditions
234
+ # Set filter object on condition and return to_array
235
+ conditions.map { |condition| instantiate_condition(condition) }.map(&:to_array)
236
+ end
237
+
238
+ def instantiate_condition(condition)
239
+ condition.set_filter(self)
240
+ translate_display(condition)
241
+ condition
242
+ end
243
+
244
+ # Set filter object on condition and sort alphabetically
245
+ def instantiated_conditions
246
+ conditions
247
+ .map { |c| instantiate_condition(c.dup) }
248
+ .sort_by { |c| c.display.to_s.downcase }
249
+ end
250
+
251
+ def translate_display(condition)
252
+ # If there are no locale definitions for this condition's subject, we can allow I18n to use a human-readable version of the ID.
253
+ # But, ideally, they have locales defined and we can find one of those.
254
+ label_fallback = {default: condition.id.humanize(keep_id_suffix: true).titleize}
255
+ condition.display = condition.display || I18n.t(".filter.conditions.#{condition.id}.label", default: I18n.t(".fields.#{condition.id}.label", **label_fallback))
256
+ end
257
+
258
+ def state
259
+ {
260
+ type: type,
261
+ blueprint: blueprint
262
+ }.to_json
263
+ end
264
+
265
+ def type
266
+ self.class.name
267
+ end
268
+
269
+ def self.from_state(state, initial_query = nil)
270
+ klass = state[:type].constantize
271
+ filter = klass.new(state[:blueprint], initial_query)
272
+ end
273
+
274
+ def self.default_stable_id_generator(klass)
275
+ if klass.method_defined?(:to_stable_id) && klass.method_defined?(:from_stable_id)
276
+ @@default_stabilizer = klass
277
+ else
278
+ raise ArgumentError.new('Given class doesn\'t implement to_stable_id and from_stable_id!')
279
+ end
280
+ end
281
+
282
+ def to_stable_id
283
+ Refine::Rails.configuration.stabilizer_classes[:url].new.to_stable_id(filter: self)
284
+ end
285
+
286
+ def blueprint_criteria
287
+ blueprint&.filter { |node| node.is_a?(Hash) && node[:type] == 'criterion' }.to_a
288
+ end
289
+
290
+ def criteria_limit_exceeded?
291
+ criteria_limit_set? && blueprint_criteria.length > criteria_limit
292
+ end
293
+
294
+ def criteria_limit_reached?
295
+ criteria_limit_set? && blueprint_criteria.length >= criteria_limit
296
+ end
297
+
298
+ def criteria_limit_set?
299
+ criteria_limit.to_i.positive?
300
+ end
301
+ end
302
+ end