feat(filter): "is one of" — so a real question can be asked

Every condition was joined by ONE global AND or OR. "2 m or 70 cm, in FT8, since
January" therefore had no expression at all: AND killed the two bands, OR let
every FT8 QSO through. The filter could ask simple questions and nothing else.

Rather than grow nested groups — a tree in the UI to answer something that is
nearly always "this field, any of these values" — the OR lives INSIDE one
condition and everything else keeps ANDing. Two operators, a comma-separated
value, no change to how the rest of the filter behaves.

Two edges that matter more than they look. An empty list matches NOTHING rather
than being dropped: dropping it would widen the result set, the opposite of what
someone typing a filter expects. And "is none of" wraps the column in IFNULL,
because raw SQL NOT IN discards NULL rows — a QSO with no band recorded is not
one of the listed bands, so it belongs in the answer.
This commit is contained in:
2026-08-11 17:07:32 +02:00
parent 904b451951
commit 91452cefc0
5 changed files with 133 additions and 10 deletions
+47
View File
@@ -1289,6 +1289,23 @@ func columnExpr(field string) (string, bool) {
}
// conditionSQL turns one condition into a parameterised predicate.
// splitList parses the comma-separated value of an "in" / "not in" condition.
//
// Blanks are dropped, so a trailing comma or a stray space while typing does not
// silently add an empty value that matches nothing. Case is left alone: the
// columns this is used on (band, mode, country) are stored in the case the log
// was written in, and forcing it here would break the ones that are not.
func splitList(v string) []string {
parts := strings.Split(v, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
func conditionSQL(c Condition) (string, []any, error) {
col, ok := columnExpr(c.Field)
if !ok {
@@ -1322,6 +1339,36 @@ func conditionSQL(c Condition) (string, []any, error) {
return col + " LIKE ?", []any{v + "%"}, nil
case "endswith":
return col + " LIKE ?", []any{"%" + v}, nil
case "in", "notin":
// Several values for ONE field, comma-separated.
//
// This is what makes a real question expressible. The filter joins every
// condition with a single AND or OR, so "2 m or 70 cm, in FT8, since
// January" could not be asked: AND killed the two bands, OR let every FT8
// QSO through. Rather than grow the model nested groups — a tree in the UI
// to answer a question that is nearly always "this field, any of these
// values" — the OR lives INSIDE one condition and everything else keeps
// ANDing.
vals := splitList(v)
if len(vals) == 0 {
// An empty list matches nothing, which is the honest reading. Silently
// dropping the condition would widen the result set instead.
if c.Op == "in" {
return "1=0", nil, nil
}
return "1=1", nil, nil
}
ph := strings.TrimSuffix(strings.Repeat("?,", len(vals)), ",")
args := make([]any, 0, len(vals))
for _, s := range vals {
args = append(args, s)
}
if c.Op == "notin" {
// IFNULL, or a NULL column would fail "NOT IN" and vanish from the
// result — the row is not one of the listed values, so it belongs.
return "IFNULL(" + col + ",'') NOT IN (" + ph + ")", args, nil
}
return col + " IN (" + ph + ")", args, nil
case "empty":
return "IFNULL(" + col + ",'') = ''", nil, nil
case "notempty":