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 Array of scalars becomes an IN lookup too: filter: { status: ["a", "b"] }
- the string "null" (or a JSON null) matches NULL rows: filter: { canceled_at: "null" }
(as a SCALAR value only — inside an IN set, elements must be non-null
scalars and "null" stays a literal string, because SQL `IN` cannot match
NULL; a null-or-nothing condition is expressed as a scalar filter)
* 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 (+ in for date)
eq, not_eq, in, matches, does_not_match — string columns (matches => case-insensitive LIKE)
eq, not_eq — boolean columns
eq, in — any other column type (uuid, enum, jsonb, ...)
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 in].freeze, string: %w[eq in not_eq matches does_not_match].freeze, text: %w[eq in not_eq matches does_not_match].freeze, boolean: %w[eq not_eq].freeze }.freeze
- DEFAULT_OPERATORS =
Operators for a column type OUTSIDE the table above (uuid, enum, jsonb, citext, ...): plain equality/IN still work on any column, so they stay available rather than turning "cannot be filtered with operators" — which also matches the API contract this replaces for adopting hosts.
%w[eq in].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"- NULL_ACCEPTING_OPERATORS =
Operators for which a null value is meaningful: eq/in render
IS NULL, not_eq rendersIS NOT NULL. Every other operator rejects null explicitly — a comparison or LIKE against NULL can never match a row in SQL, so passing one through silently would return a wrong (empty) result. %w[eq in not_eq].freeze
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
-
.arel_operator(operator, value) ⇒ Object
eqfans out to an IN lookup (normalize_value turns its value into a set) — EXCEPT against NULL: Arel rendersin(nil)asIN (NULL), which matches no rows in SQL, so eq/in with a nil value stay/becomeeq(renderedIS NULL). - .bare_value(value, 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
-
.enforce_filter_limit!(count, config) ⇒ Object
Bounds how many values an IN-set resolves to (and how many operator conditions may be ANDed on one attribute, enforced by .apply), so a valid token can't emit an unbounded IN clause / AND-chain — oversized SQL + Arel AST and expensive query planning, which matters because rate limiting is opt-in (config.rate_limit_max_requests).
-
.equality_value(value, config: McpToolkit.config) ⇒ Object
A bare (non-operator) filter value resolved for equality:
"null"/ nil => nil (matches NULL rows); a comma-separated string => its parts (IN); an Array => an IN set of non-null scalars (see .equality_value_set). -
.equality_value_set(values, config: McpToolkit.config) ⇒ Object
Resolves an Array filter value into a flat IN set.
-
.fetch(condition, key) ⇒ Object
Reads a key regardless of symbol/string keys; checks presence (not truthiness) so a literal
false/nilvalue survives. -
.mixed_collection?(value) ⇒ Boolean
An Array mixing operator conditions with bare values — rejected explicitly: neither the AND-of-conditions nor the IN-set reading fits, and treating it as either would silently match the wrong rows.
-
.normalize_value(operator, raw, config:) ⇒ Object
eq/inagainst a (possibly comma-separated string or Array) value becomes an IN set. -
.operators_for(type, config: McpToolkit.config) ⇒ Object
Operators an attribute of the given column type accepts — the single source for both the schema's advertisement and the executor's enforcement, so the two can never disagree.
-
.predicate_for(relation, column, operator, raw, config:) ⇒ Object
Builds the predicate to hand to
relation.where. - .validate_operator!(operator, type, column, config:) ⇒ Object
Class Method Details
.apply(relation, column, value, config: McpToolkit.config) ⇒ Object
Returns the relation with the filter(s) applied.
63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 |
# File 'lib/mcp_toolkit/filtering.rb', line 63 def self.apply(relation, column, value, config: McpToolkit.config) if compound?(value) apply_condition(relation, column, value, config:) elsif collection?(value) enforce_filter_limit!(value.length, config) value.inject(relation) { |rel, condition| apply_condition(rel, column, condition, config:) } elsif mixed_collection?(value) raise McpToolkit::Errors::InvalidParams, "a filter array must contain either only { op:, value: } conditions or only bare values" else # Bare value(s): equality. Under the default :tokenized semantics a # comma-separated string or an Array of scalars becomes an IN lookup and # "null" / a JSON null matches NULL rows; under :literal the value is # handed to the WHERE clause verbatim (see # Configuration#bare_filter_value_semantics). relation.where(column => (value, config)) end end |
.apply_condition(relation, column, condition, config:) ⇒ Object
122 123 124 125 126 127 128 129 130 131 |
# File 'lib/mcp_toolkit/filtering.rb', line 122 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, config:) raw = fetch(condition, :value) relation.where(predicate_for(relation, column, operator, raw, config:)) end |
.arel_operator(operator, value) ⇒ Object
eq fans out to an IN lookup (normalize_value turns its value into a set) —
EXCEPT against NULL: Arel renders in(nil) as IN (NULL), which matches no
rows in SQL, so eq/in with a nil value stay/become eq (rendered IS NULL).
204 205 206 207 208 |
# File 'lib/mcp_toolkit/filtering.rb', line 204 def self.arel_operator(operator, value) return operator unless %w[eq in].include?(operator) value.nil? ? "eq" : "in" end |
.bare_value(value, config) ⇒ Object
82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 |
# File 'lib/mcp_toolkit/filtering.rb', line 82 def self.(value, config) if value.is_a?(Hash) raise McpToolkit::Errors::InvalidParams, "unsupported filter value; use a bare scalar, an array of scalars, " \ "or { op:, value: } condition(s)" end # An Array bare value renders as `WHERE column IN (...)` under EITHER # semantics, so bound its size before the branch below. The :literal path # returns the array verbatim and would otherwise skip the limit that # equality_value/equality_value_set enforce only on the :tokenized path — # defeating the query-complexity guard on the new host-compatibility path. enforce_filter_limit!(value.length, config) if value.is_a?(Array) return value if config. == :literal equality_value(value, config:) end |
.collection?(value) ⇒ Boolean
Several operator-based conditions on one attribute, ANDed (ranges).
107 108 109 |
# File 'lib/mcp_toolkit/filtering.rb', line 107 def self.collection?(value) value.is_a?(Array) && value.any? && value.all? { |element| compound?(element) } end |
.column_type(relation, column) ⇒ Object
141 142 143 144 145 146 |
# File 'lib/mcp_toolkit/filtering.rb', line 141 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 }.
102 103 104 |
# File 'lib/mcp_toolkit/filtering.rb', line 102 def self.compound?(value) condition_hash?(value) && (value.key?(:op) || value.key?("op")) end |
.condition_hash?(value) ⇒ Boolean
118 119 120 |
# File 'lib/mcp_toolkit/filtering.rb', line 118 def self.condition_hash?(value) value.is_a?(Hash) end |
.enforce_filter_limit!(count, config) ⇒ Object
Bounds how many values an IN-set resolves to (and how many operator conditions may be ANDed on one attribute, enforced by .apply), so a valid token can't emit an unbounded IN clause / AND-chain — oversized SQL + Arel AST and expensive query planning, which matters because rate limiting is opt-in (config.rate_limit_max_requests). Disabled when config.max_filter_values is nil.
286 287 288 289 290 291 292 |
# File 'lib/mcp_toolkit/filtering.rb', line 286 def self.enforce_filter_limit!(count, config) max = config.max_filter_values return if max.nil? || count <= max raise McpToolkit::Errors::InvalidParams, "a filter may carry at most #{max} values or conditions (got #{count})" end |
.equality_value(value, config: McpToolkit.config) ⇒ Object
A bare (non-operator) filter value resolved for equality: "null" / nil =>
nil (matches NULL rows); a comma-separated string => its parts (IN); an
Array => an IN set of non-null scalars (see .equality_value_set). Any other
non-scalar (an op-less Hash) is rejected — passed through it would reach the
database as a malformed condition.
240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 |
# File 'lib/mcp_toolkit/filtering.rb', line 240 def self.equality_value(value, config: McpToolkit.config) return equality_value_set(value, config:) if value.is_a?(Array) return nil if value.nil? || value.to_s == NULL_TOKEN if value.is_a?(Hash) raise McpToolkit::Errors::InvalidParams, "unsupported filter value; use a bare scalar, an array of scalars, " \ "or { op:, value: } condition(s)" end str = value.to_s return value unless str.include?(",") parts = str.split(",") enforce_filter_limit!(parts.length, config) parts end |
.equality_value_set(values, config: McpToolkit.config) ⇒ Object
Resolves an Array filter value into a flat IN set. Elements must be non-null
scalars (comma-separated strings are split in); nil / nested Array / Hash
elements are rejected explicitly — SQL IN cannot match NULL (Arel renders
a nil element as the never-matching IN (..., NULL)), and a non-scalar
element is a malformed condition either way. The "null" token is NOT
resolved inside a set for the same reason: it stays a literal string, and a
null-or-nothing condition is expressed as a scalar filter value instead.
265 266 267 268 269 270 271 272 273 274 275 276 277 278 |
# File 'lib/mcp_toolkit/filtering.rb', line 265 def self.equality_value_set(values, config: McpToolkit.config) resolved = values.flat_map do |element| if element.nil? || element.is_a?(Array) || element.is_a?(Hash) raise McpToolkit::Errors::InvalidParams, "an IN-set filter must contain only non-null scalar values; " \ "to match NULL rows pass \"null\" as the filter's single value" end str = element.to_s str.include?(",") ? str.split(",") : [element] end enforce_filter_limit!(resolved.length, config) resolved end |
.fetch(condition, key) ⇒ Object
Reads a key regardless of symbol/string keys; checks presence (not
truthiness) so a literal false / nil value survives.
135 136 137 138 139 |
# File 'lib/mcp_toolkit/filtering.rb', line 135 def self.fetch(condition, key) return condition[key] if condition.key?(key) condition[key.to_s] if condition.key?(key.to_s) end |
.mixed_collection?(value) ⇒ Boolean
An Array mixing operator conditions with bare values — rejected explicitly: neither the AND-of-conditions nor the IN-set reading fits, and treating it as either would silently match the wrong rows.
114 115 116 |
# File 'lib/mcp_toolkit/filtering.rb', line 114 def self.mixed_collection?(value) value.is_a?(Array) && value.any? { |element| compound?(element) } end |
.normalize_value(operator, raw, config:) ⇒ Object
eq / in against a (possibly comma-separated string or Array) value
becomes an IN set. matches / does_not_match wrap the value in %...%
with LIKE wildcards escaped so they match literally. null / a JSON null
=> nil for the NULL_ACCEPTING_OPERATORS, rejected for every other operator.
214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 |
# File 'lib/mcp_toolkit/filtering.rb', line 214 def self.normalize_value(operator, raw, config:) if raw.nil? || raw.to_s == NULL_TOKEN unless NULL_ACCEPTING_OPERATORS.include?(operator) raise McpToolkit::Errors::InvalidParams, "'#{operator}' does not accept a null value " \ "(only #{NULL_ACCEPTING_OPERATORS.join("/")} do)" end return nil end case operator when "eq", "in" resolved = equality_value(raw, config:) resolved.is_a?(Array) ? resolved : [resolved] when "matches", "does_not_match" "%#{config.sql_sanitizer.sanitize_sql_like(raw.to_s)}%" else raw end end |
.operators_for(type, config: McpToolkit.config) ⇒ Object
Operators an attribute of the given column type accepts — the single source
for both the schema's advertisement and the executor's enforcement, so the
two can never disagree. [] for an attribute with no backing column (which
cannot be filtered with operators). A host can override sets per type via
config.filter_operator_overrides (e.g. to preserve a pre-gem contract).
153 154 155 156 157 |
# File 'lib/mcp_toolkit/filtering.rb', line 153 def self.operators_for(type, config: McpToolkit.config) return [] if type.nil? config.filter_operator_overrides.fetch(type) { OPERATORS_BY_TYPE.fetch(type, DEFAULT_OPERATORS) } 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.
177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 |
# File 'lib/mcp_toolkit/filtering.rb', line 177 def self.predicate_for(relation, column, operator, raw, config:) value = normalize_value(operator, raw, config:) method = arel_operator(operator, value) # Defense in depth: only a known-safe Arel predication may ever reach # public_send. operators_for/validate_operator! already gate this and # config.filter_operator_overrides is validated at assignment — but the # value handed to any operator outside eq/in/matches is passed through # VERBATIM (normalize_value's else branch), so an operator that slipped in # (e.g. a host-configured "extract") must never be dispatched: Arel would # interpolate the request value as raw SQL. This is the last guard before # the metaprogramming call. unless AREL_PREDICATIONS.include?(method) raise McpToolkit::Errors::InvalidParams, "'#{operator}' is not a supported filter operator" end model = relation.respond_to?(:model) ? relation.model : nil if model.respond_to?(:arel_table) model.arel_table[column.to_sym].public_send(method, value) else Predicate.new(column.to_sym, method, value) end end |
.validate_operator!(operator, type, column, config:) ⇒ Object
159 160 161 162 163 164 165 166 167 168 169 170 171 |
# File 'lib/mcp_toolkit/filtering.rb', line 159 def self.validate_operator!(operator, type, column, config:) if type.nil? raise McpToolkit::Errors::InvalidParams, "'#{column}' cannot be filtered with operators" end allowed = operators_for(type, config:) return if allowed.include?(operator) raise McpToolkit::Errors::InvalidParams, "'#{operator}' operator is not supported for #{column} (#{type}). " \ "Supported operators: #{allowed.join(", ")}." end |