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:
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user