# frozen_string_literal: true module Praxis module Extensions module AttributeFiltering ALIAS_TABLE_PREFIX = '' require_relative 'active_record_patches' # Helper class that can present an SqlLiteral string which we have already quoted # ... but! that can properly provide a "to_sym" that has the value unquoted # This is necessary as (the latest AR code): # * does not carry over "references" in joins if they are not SqlLiterals # * but, at the same time, it indexes the references using the .to_sym value (which is really expected to be the normal string, without quotes) # If we pass a normal SqlLiteral, instead of our wrapper, without quoting the table, the current AR code will never quote it to form the # SQL string, as it's already a literal...so our "/" type separators as names won't work without quoting. class QuasiSqlLiteral < Arel::Nodes::SqlLiteral def initialize(quoted:, symbolized:) @symbolized = symbolized super(quoted) end def to_sym @symbolized end end class ActiveRecordFilterQueryBuilder REFERENCES_STRING_SEPARATOR = '/' attr_reader :model, :filters_map # Base query to build upon def initialize(query:, model:, filters_map:, debug: false) # NOTE: Do not make the initial_query an attr reader to make sure we don't count/leak on modifying it. Easier to mostly use class methods @initial_query = query @model = model @filters_map = filters_map @logger = debug ? Logger.new($stdout) : nil @active_record_version = ActiveRecord.gem_version end def debug_query(msg, query) @logger&.info(msg + query.to_sql) end def generate(filters) # Resolve the names and values first, based on filters_map root_node = _convert_to_treenode(filters) crafted = craft_filter_query(root_node, for_model: @model) debug_query('SQL due to filters: ', crafted.all) crafted end def craft_filter_query(nodetree, for_model:) result = _compute_joins_and_conditions_data(nodetree, model: for_model, parent_reflection: nil) return @initial_query if result[:conditions].empty? # Find the root group (usually an AND group) but can be an OR group, or nil if there's only 1 condition root_parent_group = result[:conditions].first[:node_object].parent_group || result[:conditions].first[:node_object] root_parent_group = root_parent_group.parent_group until root_parent_group.parent_group.nil? # Process the joins query_with_joins = result[:associations_hash].empty? ? @initial_query : @initial_query.left_outer_joins(result[:associations_hash]) # Proc to apply a single condition apply_single_condition = proc do |condition, associated_query| colo = condition[:model].columns_hash[condition[:name].to_s] column_prefix = condition[:column_prefix] association_key_column = \ if (ref = condition[:parent_reflection]) # get the target model of the association(where the assoc pk is) target_model = ref.klass target_model.columns_hash[ref.association_primary_key] end # Mark where clause referencing the appropriate alias IF it's not the root table, as there is no association to reference # If we added root table as a reference, we better make sure it is not quoted, as it actually makes AR to see it as an # unmatched reference and eager loads the whole association (it means eager load ALL the things). Not good. associated_query = associated_query.references(self.class.build_reference_value(column_prefix, query: associated_query)) unless for_model.table_name == column_prefix self.class.add_clause( query: associated_query, column_prefix: column_prefix, column_object: colo, op: condition[:op], value: condition[:value], fuzzy: condition[:fuzzy], association_key_column: association_key_column ) end if @active_record_version < Gem::Version.new('6') # ActiveRecord < 6 does not support '.and' so no nested things can be done # But we can still support the case of 1+ flat conditions of the same AND/OR type if root_parent_group.is_a?(FilteringParams::Condition) # A Single condition it is easy to handle apply_single_condition.call(result[:conditions].first, query_with_joins) elsif root_parent_group.items.all? { |i| i.is_a?(FilteringParams::Condition) } # Only 1 top level root, with only with simple condition items if root_parent_group.type == :and result[:conditions].reverse.inject(query_with_joins) do |accum, condition| apply_single_condition.call(condition, accum) end else # To do a flat OR, we need to apply the first condition to the incoming query # and then apply any extra ORs to it. Otherwise Book.or(X).or(X) still matches all books cond1, *rest = result[:conditions].reverse start_query = apply_single_condition.call(cond1, query_with_joins) rest.inject(start_query) do |accum, condition| accum.or(apply_single_condition.call(condition, query_with_joins)) end end else raise 'Mixing AND and OR conditions is not supported for ActiveRecord <6.' end else # ActiveRecord 6+ # Process the conditions in a depth-first order, and return the resulting query _depth_first_traversal( root_query: query_with_joins, root_node: root_parent_group, conditions: result[:conditions], &apply_single_condition ) end end # not in filters....checks if it's a valid path # array of strings def self.valid_path?(model, path) first_component, *rest = path if model.attribute_names.include?(first_component) true elsif model.reflections.keys.include?(first_component) if rest.empty? true # Allow associations as a leaf too (as they can have the ! and !! operator) else # Follow the association nested_model = model.reflections[first_component].klass valid_path?(nested_model, rest) end else false end end # rubocop:disable Metrics/ParameterLists,Naming/MethodParameterName def self.add_clause(query:, column_prefix:, column_object:, op:, value:, fuzzy:, association_key_column:) likeval = get_like_value(value, fuzzy) association_op = nil case op when '!' # name! means => name IS NOT NULL (and the incoming value is nil) op = '!=' value = nil # Enforce it is indeed nil (should be) association_op = :not_null if association_key_column && !column_object when '!!' op = '=' value = nil # Enforce it is indeed nil (should be) association_op = :null if association_key_column && !column_object end if association_op neg = association_op == :not_null qr = quote_right_part(query: query, value: nil, column_object: association_key_column, negative: neg) return query.where("#{quote_column_path(query: query, prefix: column_prefix, column_name: association_key_column.name)} #{qr}") end # Add an AND along with the condition, which ensures the left outter join 'exists' for it # Normally this wouldn't be necessary as a condition on a given value mathing would imply the related row was there # but this is not the case for NULL conditions, as the foreign column would match a NULL value, but not because the related column # is NULL, but because the whole missing related row would appear with all fields null # NOTE: we don't need to do it for conditions applying to the root of the tree (there isn't a join to it) if association_key_column qr = quote_right_part(query: query, value: nil, column_object: association_key_column, negative: true) query = query.where("#{quote_column_path(query: query, prefix: column_prefix, column_name: association_key_column.name)} #{qr}") end case op when '=' if likeval add_safe_where(query: query, tab: column_prefix, col: column_object, op: 'LIKE', value: likeval) else quoted_right = quote_right_part(query: query, value: value, column_object: column_object, negative: false) query.where("#{quote_column_path(query: query, prefix: column_prefix, column_name: column_object.name)} #{quoted_right}") end when '!=' if likeval add_safe_where(query: query, tab: column_prefix, col: column_object, op: 'NOT LIKE', value: likeval) else quoted_right = quote_right_part(query: query, value: value, column_object: column_object, negative: true) query.where("#{quote_column_path(query: query, prefix: column_prefix, column_name: column_object.name)} #{quoted_right}") end when '>' add_safe_where(query: query, tab: column_prefix, col: column_object, op: '>', value: value) when '<' add_safe_where(query: query, tab: column_prefix, col: column_object, op: '<', value: value) when '>=' add_safe_where(query: query, tab: column_prefix, col: column_object, op: '>=', value: value) when '<=' add_safe_where(query: query, tab: column_prefix, col: column_object, op: '<=', value: value) else raise "Unsupported Operator!!! #{op}" end end # rubocop:enable Metrics/ParameterLists,Naming/MethodParameterName # rubocop:disable Naming/MethodParameterName def self.add_safe_where(query:, tab:, col:, op:, value:) quoted_value = query.connection.quote_default_expression(value, col) query.where("#{quote_column_path(query: query, prefix: tab, column_name: col.name)} #{op} #{quoted_value}") end # rubocop:enable Naming/MethodParameterName def self.quote_column_path(query:, prefix:, column_name:) c = query.connection quoted_column = c.quote_column_name(column_name) if prefix quoted_table = c.quote_table_name(prefix) "#{quoted_table}.#{quoted_column}" else quoted_column end end def self.quote_right_part(query:, value:, column_object:, negative:) conn = query.connection if value.nil? no = negative ? ' NOT' : '' "IS#{no} #{conn.quote_default_expression(value, column_object)}" elsif value.is_a?(Array) no = negative ? 'NOT ' : '' list = value.map { |v| conn.quote_default_expression(v, column_object) } "#{no}IN (#{list.join(',')})" elsif value.is_a?(Range) raise 'TODO!' else op = negative ? '<>' : '=' "#{op} #{conn.quote_default_expression(value, column_object)}" end end # Returns nil if the value was not a fuzzzy pattern def self.get_like_value(value, fuzzy) is_fuzzy = fuzzy.is_a?(Array) ? !fuzzy.compact.empty? : fuzzy return unless is_fuzzy raise MultiMatchWithFuzzyNotAllowedByAdapter unless value.is_a?(String) case fuzzy when :start_end "%#{value}%" when :start "%#{value}" when :end "#{value}%" end end private def _depth_first_traversal(root_query:, root_node:, conditions:, &block) # Save the associated query for non-leaves root_node.associated_query = root_query if root_node.is_a?(FilteringParams::ConditionGroup) if root_node.is_a?(FilteringParams::Condition) matching_condition = conditions.find { |cond| cond[:node_object] == root_node } # The simplified case of a single top level condition (without a wrapping group) # will need to pass the root query itself associated_query = root_node.parent_group ? root_node.parent_group.associated_query : root_query yield matching_condition, associated_query else first_query, *rest_queries = root_node.items.map do |child| _depth_first_traversal(root_query: root_query, root_node: child, conditions: conditions, &block) end rest_queries.each.inject(first_query) do |q, a_query| root_node.type == :and ? q.and(a_query) : q.or(a_query) end end end def _mapped_filter(name) target = @filters_map[name] unless target path = name.to_s.split('.') if self.class.valid_path?(@model, path) # Cache it in the filters mapping (to avoid later lookups), and return it. @filters_map[name] = name target = name end end target end # Resolve and convert from filters, to a more manageable and param-type-independent structure def _convert_to_treenode(filters) # Resolve the names and values first, based on filters_map resolved_array = [] filters.parsed_array.each do |filter| mapped_value = _mapped_filter(filter[:name]) unless mapped_value msg = "Filtering by #{filter[:name]} is not allowed. No implementation mapping defined for it has been found \ and there is not a model attribute with this name either.\n" \ "Please add a mapping for #{filter[:name]} in the `filters_mapping` method of the appropriate Resource class" raise msg end bindings_array = \ if mapped_value.is_a?(Proc) result = mapped_value.call(filter) # Result could be an array of hashes (each hash has name/op/value to identify a condition) result_from_proc = result.is_a?(Array) ? result : [result] # Make sure we tack on the node object associated with the filter result_from_proc.map { |hash| hash.merge(node_object: filter[:node_object]) } else # For non-procs there's only 1 filter and 1 value (we're just overriding the mapped value) [filter.merge(name: mapped_value)] end resolved_array += bindings_array end FilterTreeNode.new(resolved_array, path: [ALIAS_TABLE_PREFIX]) end # Calculate join tree and conditions array for the nodetree object and its children def _compute_joins_and_conditions_data(nodetree, model:, parent_reflection:) h = {} conditions = [] nodetree.children.each do |name, child| child_reflection = model.reflections[name.to_s] result = _compute_joins_and_conditions_data(child, model: child_reflection.klass, parent_reflection: child_reflection) h[name] = result[:associations_hash] conditions += result[:conditions] end column_prefix = nodetree.path == [ALIAS_TABLE_PREFIX] ? model.table_name : nodetree.path.join(REFERENCES_STRING_SEPARATOR) nodetree.conditions.each do |condition| # If it's a final ! or !! operation on an association from the parent, it means we need to add a condition # on the existence (or lack of) of the whole associated table ref = model.reflections[condition[:name].to_s] if ref && ['!', '!!'].include?(condition[:op]) cp = (nodetree.path + [condition[:name].to_s]).join(REFERENCES_STRING_SEPARATOR) conditions += [condition.merge(column_prefix: cp, model: model, parent_reflection: ref)] h[condition[:name].to_s] = {} else # Save the parent reflection where the condition applies as well (used later to get assoc keys) conditions += [condition.merge(column_prefix: column_prefix, model: model, parent_reflection: parent_reflection)] end end { associations_hash: h, conditions: conditions } end # The value that we need to stick in the references method is different in the latest Rails if ActiveRecord.gem_version < Gem::Version.new('6') # In AR 6 (and 6.0) the references are simple strings def self.build_reference_value(column_prefix, **_args) column_prefix end else # The latest AR versions discard passing references to joins when they're not SqlLiterals ... so let's wrap it # with our class, so that it is a literal (already quoted), but that can still provide the expected "symbol" without quotes # so that our aliasing code can match it. def self.build_reference_value(column_prefix, query:) QuasiSqlLiteral.new(quoted: query.connection.quote_table_name(column_prefix), symbolized: column_prefix.to_sym) end end end end end end