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:
@@ -0,0 +1,57 @@
|
||||
package qso
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// "2 m or 70 cm, in FT8, since January" could not be asked before: the filter
|
||||
// joins every condition with ONE AND or OR, so AND killed the two bands and OR
|
||||
// let every FT8 QSO through. The OR now lives inside a single condition.
|
||||
func TestInConditionExpressesSeveralValuesForOneField(t *testing.T) {
|
||||
sql, args, err := conditionSQL(Condition{Field: "band", Op: "in", Value: "2m, 70cm"})
|
||||
if err != nil {
|
||||
t.Fatalf("in: %v", err)
|
||||
}
|
||||
if !strings.Contains(sql, "IN (?,?)") {
|
||||
t.Errorf("sql = %q, want an IN with two placeholders", sql)
|
||||
}
|
||||
if len(args) != 2 || args[0] != "2m" || args[1] != "70cm" {
|
||||
t.Errorf("args = %v, want [2m 70cm] trimmed", args)
|
||||
}
|
||||
}
|
||||
|
||||
// A trailing comma while typing must not add an empty value that matches nothing.
|
||||
func TestInIgnoresBlanks(t *testing.T) {
|
||||
_, args, err := conditionSQL(Condition{Field: "mode", Op: "in", Value: "FT8, ,FT4,"})
|
||||
if err != nil {
|
||||
t.Fatalf("in: %v", err)
|
||||
}
|
||||
if len(args) != 2 {
|
||||
t.Errorf("args = %v, want the two real values only", args)
|
||||
}
|
||||
}
|
||||
|
||||
// An empty list matches nothing. Dropping the condition instead would WIDEN the
|
||||
// result set — the opposite of what someone typing a filter expects.
|
||||
func TestEmptyInMatchesNothing(t *testing.T) {
|
||||
sql, _, err := conditionSQL(Condition{Field: "band", Op: "in", Value: " , "})
|
||||
if err != nil {
|
||||
t.Fatalf("in: %v", err)
|
||||
}
|
||||
if sql != "1=0" {
|
||||
t.Errorf("sql = %q, want 1=0", sql)
|
||||
}
|
||||
}
|
||||
|
||||
// NOT IN must keep rows whose column is NULL: the row is not one of the listed
|
||||
// values, so it belongs in the answer. Raw SQL NOT IN drops them.
|
||||
func TestNotInKeepsNulls(t *testing.T) {
|
||||
sql, _, err := conditionSQL(Condition{Field: "band", Op: "notin", Value: "2m"})
|
||||
if err != nil {
|
||||
t.Fatalf("notin: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(sql, "IFNULL(") {
|
||||
t.Errorf("sql = %q, want the column wrapped in IFNULL", sql)
|
||||
}
|
||||
}
|
||||
@@ -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