feat: add $consider (UP/DOWN) toggle to query builder and evaluators

This commit is contained in:
M1 2026-03-16 13:56:36 +04:00
parent 5328471229
commit 27c9044a8b
3 changed files with 46 additions and 9 deletions

View File

@ -50,6 +50,17 @@ pub struct Response {
pub fn evaluate(query: &Value, response: &Response) -> Result<bool> {
match query {
Value::Object(map) => {
// $consider — "up" (default) or "down": flips result if conditions match
if let Some(consider) = map.get("$consider") {
let is_down = consider.as_str() == Some("down");
let rest: serde_json::Map<String, Value> = map.iter()
.filter(|(k, _)| k.as_str() != "$consider")
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
let matches = evaluate(&Value::Object(rest), response).unwrap_or(false);
return Ok(if is_down { !matches } else { matches });
}
// $and / $or / $not
if let Some(and) = map.get("$and") {
let Value::Array(clauses) = and else { bail!("$and expects array") };

View File

@ -21,6 +21,7 @@ class QueryBuilder {
this.container = container;
this.onChange = onChange;
this.logic = '$and';
this.consider = 'up'; // 'up' | 'down'
this.rules = [this._emptyRule()];
this.render();
}
@ -34,8 +35,9 @@ class QueryBuilder {
.map(r => this._ruleToQuery(r))
.filter(Boolean);
if (conditions.length === 0) return null;
if (conditions.length === 1) return conditions[0];
return { [this.logic]: conditions };
const base = conditions.length === 1 ? conditions[0] : { [this.logic]: conditions };
if (this.consider === 'down') return { $consider: 'down', ...base };
return base;
}
_ruleToQuery(rule) {
@ -86,18 +88,23 @@ class QueryBuilder {
if (!query || typeof query !== 'object') {
this.rules = [this._emptyRule()];
this.logic = '$and';
this.consider = 'up';
this.render();
return;
}
if ('$and' in query || '$or' in query) {
this.logic = '$and' in query ? '$and' : '$or';
const clauses = query[this.logic];
// Strip $consider before parsing rules
this.consider = query.$consider === 'down' ? 'down' : 'up';
const q = Object.fromEntries(Object.entries(query).filter(([k]) => k !== '$consider'));
if ('$and' in q || '$or' in q) {
this.logic = '$and' in q ? '$and' : '$or';
const clauses = q[this.logic];
if (Array.isArray(clauses)) {
this.rules = clauses.map(c => this._queryToRule(c)).filter(Boolean);
}
} else {
this.rules = [this._queryToRule(query)].filter(Boolean);
this.rules = [this._queryToRule(q)].filter(Boolean);
}
if (this.rules.length === 0) this.rules = [this._emptyRule()];
@ -164,13 +171,18 @@ class QueryBuilder {
this.container.innerHTML = `
<div class="space-y-3">
<div class="flex items-center gap-2 mb-4">
<span class="text-sm text-gray-400">Match</span>
<div class="flex items-center gap-2 mb-4 flex-wrap">
<span class="text-sm text-gray-400">Consider</span>
<select id="qb-consider" class="bg-gray-800 border border-gray-700 text-sm rounded px-2 py-1 focus:border-blue-500 focus:outline-none font-medium ${this.consider === 'down' ? 'text-red-400' : 'text-green-400'}">
<option value="up" ${this.consider === 'up' ? 'selected' : ''}>UP</option>
<option value="down" ${this.consider === 'down' ? 'selected' : ''}>DOWN</option>
</select>
<span class="text-sm text-gray-400">when</span>
<select id="qb-logic" class="bg-gray-800 border border-gray-700 text-gray-200 text-sm rounded px-2 py-1 focus:border-blue-500 focus:outline-none">
<option value="$and" ${this.logic === '$and' ? 'selected' : ''}>ALL</option>
<option value="$or" ${this.logic === '$or' ? 'selected' : ''}>ANY</option>
</select>
<span class="text-sm text-gray-400">of the following conditions</span>
<span class="text-sm text-gray-400">of the following match</span>
</div>
<div id="qb-rules" class="space-y-2">
@ -190,6 +202,12 @@ class QueryBuilder {
`;
// Bind events
this.container.querySelector('#qb-consider').addEventListener('change', (e) => {
this.consider = e.target.value;
this.render();
this.onChange?.(this.getQuery());
});
this.container.querySelector('#qb-logic').addEventListener('change', (e) => {
this.logic = e.target.value;
this.render();

View File

@ -86,6 +86,14 @@ export function evaluate(query: unknown, ctx: EvalContext): boolean {
const q = query as Record<string, unknown>;
// $consider — "up" (default) or "down": flips result if conditions match
if ("$consider" in q) {
const consider = q.$consider as string;
const rest = Object.fromEntries(Object.entries(q).filter(([k]) => k !== "$consider"));
const matches = evaluate(rest, ctx);
return consider === "down" ? !matches : matches;
}
// $and
if ("$and" in q) {
const clauses = q.$and;