Module: McpToolkit::Filtering
- Defined in:
- lib/mcp_toolkit/filtering.rb
Overview
Applies the list tool's filter params to an already-scoped relation, with
the following semantics:
* a BARE value filters by equality: filter: { booking_id: 42 }
(a comma-separated string becomes an IN lookup: filter: { status: "a,b" })
* an { op:, value: } HASH filters with an operator: filter: { price: { op: "gteq", value: 100 } }
* an ARRAY of those hashes ANDs them (ranges):
filter: { price: [{ op: "gteq", value: 100 }, { op: "lt", value: 200 }] }
Supported operators (validated against the column's DB type):
eq, not_eq, gt, gteq, lt, lteq — numeric / datetime columns
eq, not_eq, in, matches, does_not_match — string columns (matches => case-insensitive LIKE)
eq, not_eq — boolean columns
Allowlist-safe: only the resource's declared filterable keys may be filtered,
each resolved to its backing column. Unknown keys are rejected upstream by the
ListExecutor, and an operator unsupported for a column's type raises
InvalidParams — there is no arbitrary column or SQL injection surface.
Defined Under Namespace
Classes: Predicate
Constant Summary collapse
- OPERATORS_BY_TYPE =
{ integer: %w[eq not_eq gt gteq lt lteq].freeze, float: %w[eq not_eq gt gteq lt lteq].freeze, decimal: %w[eq not_eq gt gteq lt lteq].freeze, datetime: %w[eq not_eq gt gteq lt lteq].freeze, date: %w[eq not_eq gt gteq lt lteq].freeze, string: %w[eq not_eq in matches does_not_match].freeze, text: %w[eq not_eq in matches does_not_match].freeze, boolean: %w[eq not_eq].freeze }.freeze
- AREL_PREDICATIONS =
Operators that map straight onto an Arel predication method.
%w[eq not_eq gt gteq lt lteq in matches does_not_match].freeze
- NULL_TOKEN =
"null"
Class Method Summary collapse
-
.apply(relation, column, value, config: McpToolkit.config) ⇒ Object
The relation with the filter(s) applied.
- .apply_condition(relation, column, condition, config:) ⇒ Object
-
.collection?(value) ⇒ Boolean
Several operator-based conditions on one attribute, ANDed (ranges).
- .column_type(relation, column) ⇒ Object
-
.compound?(value) ⇒ Boolean
A single operator-based condition, e.g.
- .condition_hash?(value) ⇒ Boolean
- .equality_value(value) ⇒ Object
-
.fetch(condition, key) ⇒ Object
Reads a key regardless of symbol/string keys; checks presence (not truthiness) so a literal
false/nilvalue survives. -
.normalize_value(operator, raw, config:) ⇒ Object
eqagainst a (possibly comma-separated) value becomes an IN set. -
.predicate_for(relation, column, operator, raw, config:) ⇒ Object
Builds the predicate to hand to
relation.where. - .validate_operator!(operator, type, column) ⇒ Object
Class Method Details
.apply(relation, column, value, config: McpToolkit.config) ⇒ Object
Returns the relation with the filter(s) applied.
45 46 47 48 49 50 51 52 53 54 55 |
# File 'lib/mcp_toolkit/filtering.rb', line 45 def self.apply(relation, column, value, config: McpToolkit.config) if compound?(value) apply_condition(relation, column, value, config:) elsif collection?(value) value.inject(relation) { |rel, condition| apply_condition(rel, column, condition, config:) } else # Bare value: equality. A comma-separated string becomes an IN lookup, # matching the implicit `eq` semantics. relation.where(column => equality_value(value)) end end |
.apply_condition(relation, column, condition, config:) ⇒ Object
71 72 73 74 75 76 77 78 79 80 |
# File 'lib/mcp_toolkit/filtering.rb', line 71 def self.apply_condition(relation, column, condition, config:) operator = fetch(condition, :op).to_s raise McpToolkit::Errors::InvalidParams, "a filter operator is required" if operator.empty? type = column_type(relation, column) validate_operator!(operator, type, column) raw = fetch(condition, :value) relation.where(predicate_for(relation, column, operator, raw, config:)) end |
.collection?(value) ⇒ Boolean
Several operator-based conditions on one attribute, ANDed (ranges).
63 64 65 |
# File 'lib/mcp_toolkit/filtering.rb', line 63 def self.collection?(value) value.is_a?(Array) && value.any? && value.all? { |element| compound?(element) } end |
.column_type(relation, column) ⇒ Object
90 91 92 93 94 95 |
# File 'lib/mcp_toolkit/filtering.rb', line 90 def self.column_type(relation, column) model = relation.respond_to?(:model) ? relation.model : nil return nil unless model.respond_to?(:columns_hash) model.columns_hash[column.to_s]&.type end |
.compound?(value) ⇒ Boolean
A single operator-based condition, e.g. { op: "gt", value: 1000 }.
58 59 60 |
# File 'lib/mcp_toolkit/filtering.rb', line 58 def self.compound?(value) condition_hash?(value) && (value.key?(:op) || value.key?("op")) end |
.condition_hash?(value) ⇒ Boolean
67 68 69 |
# File 'lib/mcp_toolkit/filtering.rb', line 67 def self.condition_hash?(value) value.is_a?(Hash) end |
.equality_value(value) ⇒ Object
142 143 144 145 146 147 |
# File 'lib/mcp_toolkit/filtering.rb', line 142 def self.equality_value(value) return nil if value.to_s == NULL_TOKEN str = value.to_s str.include?(",") ? str.split(",") : value end |
.fetch(condition, key) ⇒ Object
Reads a key regardless of symbol/string keys; checks presence (not
truthiness) so a literal false / nil value survives.
84 85 86 87 88 |
# File 'lib/mcp_toolkit/filtering.rb', line 84 def self.fetch(condition, key) return condition[key] if condition.key?(key) condition[key.to_s] if condition.key?(key.to_s) end |
.normalize_value(operator, raw, config:) ⇒ Object
eq against a (possibly comma-separated) value becomes an IN set. matches
/ does_not_match wrap the value in %...% with LIKE wildcards escaped so
they match literally. null => nil for every operator.
129 130 131 132 133 134 135 136 137 138 139 140 |
# File 'lib/mcp_toolkit/filtering.rb', line 129 def self.normalize_value(operator, raw, config:) return nil if raw.to_s == NULL_TOKEN case operator when "eq", "in" raw.to_s.split(",") when "matches", "does_not_match" "%#{config.sql_sanitizer.sanitize_sql_like(raw.to_s)}%" else raw end end |
.predicate_for(relation, column, operator, raw, config:) ⇒ Object
Builds the predicate to hand to relation.where. For a real ActiveRecord
relation we build an Arel node (so it composes with the scope safely and is
immune to SQL injection); a relation without an arel_table (the in-memory
test fake) receives a portable Predicate value object it knows how to apply.
114 115 116 117 118 119 120 121 122 123 124 |
# File 'lib/mcp_toolkit/filtering.rb', line 114 def self.predicate_for(relation, column, operator, raw, config:) value = normalize_value(operator, raw, config:) arel_operator = operator == "eq" ? "in" : operator model = relation.respond_to?(:model) ? relation.model : nil if model.respond_to?(:arel_table) model.arel_table[column.to_sym].public_send(arel_operator, value) else Predicate.new(column.to_sym, arel_operator, value) end end |
.validate_operator!(operator, type, column) ⇒ Object
97 98 99 100 101 102 103 104 105 106 107 108 |
# File 'lib/mcp_toolkit/filtering.rb', line 97 def self.validate_operator!(operator, type, column) allowed = OPERATORS_BY_TYPE[type] if allowed.nil? raise McpToolkit::Errors::InvalidParams, "'#{column}' cannot be filtered with operators" end return if allowed.include?(operator) raise McpToolkit::Errors::InvalidParams, "'#{operator}' operator is not supported for #{column} (#{type}). " \ "Supported operators: #{allowed.join(", ")}." end |