refine-rails 2.9.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.
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