fix(filter): "equals nothing" found nothing
A NULL never equals '', so a condition written that way returned zero rows. The question is perfectly clear — the operator wants the ones with nothing in that field — and answering it with silence makes the filter look broken rather than mis-stated. eq and ne with a blank value now run the empty / not-empty test. Empty on a NUMERIC column also had a real fault behind it. IFNULL(col,'')='' compares 0 against '', which SQLite calls false and MySQL calls true — one expression quietly answering two different questions depending on where the logbook lives. Numeric columns test NULL or zero explicitly now; text keeps the string test, where '' is a real value and 0 is not.
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
package qso
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// "equals nothing" and "is empty" are the same question. SQL answers the first
|
||||
// with nothing at all — a NULL never equals ” — so a filter written in plain
|
||||
// words returned zero rows and looked broken rather than wrong.
|
||||
func TestEqualsBlankMeansEmpty(t *testing.T) {
|
||||
sql, args, err := conditionSQL(Condition{Field: "freq_hz", Op: "eq", Value: ""})
|
||||
if err != nil {
|
||||
t.Fatalf("eq blank: %v", err)
|
||||
}
|
||||
if len(args) != 0 || !strings.Contains(sql, "IS NULL") {
|
||||
t.Errorf("sql = %q args = %v — want the empty test", sql, args)
|
||||
}
|
||||
sql, _, _ = conditionSQL(Condition{Field: "name", Op: "ne", Value: " "})
|
||||
if !strings.Contains(sql, "<> ''") {
|
||||
t.Errorf("ne blank on text gave %q — want the not-empty test", sql)
|
||||
}
|
||||
}
|
||||
|
||||
// A numeric column is empty when NULL *or* zero, and that must be explicit:
|
||||
// SQLite compares 0 against ” as false while MySQL calls it true, so one
|
||||
// expression would answer two different questions depending on the backend.
|
||||
func TestEmptyOnNumericCoversZeroAndNull(t *testing.T) {
|
||||
sql, _, err := conditionSQL(Condition{Field: "freq_hz", Op: "empty"})
|
||||
if err != nil {
|
||||
t.Fatalf("empty: %v", err)
|
||||
}
|
||||
if !strings.Contains(sql, "IS NULL") || !strings.Contains(sql, "= 0") {
|
||||
t.Errorf("sql = %q — want both NULL and zero", sql)
|
||||
}
|
||||
// Text keeps the string test: '' is a real value there, 0 is not.
|
||||
sql, _, _ = conditionSQL(Condition{Field: "name", Op: "empty"})
|
||||
if !strings.Contains(sql, "IFNULL") || strings.Contains(sql, "= 0") {
|
||||
t.Errorf("text empty gave %q", sql)
|
||||
}
|
||||
}
|
||||
@@ -1266,6 +1266,17 @@ var filterableColumns = map[string]bool{
|
||||
// value compares on the date part (see conditionSQL) so day filters are exact.
|
||||
var dateColumns = map[string]bool{"qso_date": true, "qso_date_off": true}
|
||||
|
||||
// numericColumns are the filterable columns holding numbers rather than text.
|
||||
//
|
||||
// "Empty" means something different for them: NULL *or* zero. It has to be said
|
||||
// explicitly because the two backends disagree — SQLite compares 0 against ”
|
||||
// as false, MySQL calls it true — so one expression would quietly answer two
|
||||
// different questions depending on where the logbook lives.
|
||||
var numericColumns = map[string]bool{
|
||||
"freq_hz": true, "freq_rx_hz": true, "dxcc": true, "cqz": true, "ituz": true,
|
||||
"srx": true, "stx": true, "tx_pwr": true,
|
||||
}
|
||||
|
||||
// bareDateRe matches a plain calendar date with no time component.
|
||||
var bareDateRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
|
||||
|
||||
@@ -1366,6 +1377,18 @@ func conditionSQL(c Condition) (string, []any, error) {
|
||||
col = "substr(" + col + ",1,10)"
|
||||
v = strings.TrimSpace(v)
|
||||
}
|
||||
// "equals nothing" and "is empty" are the same question, and SQL answers the
|
||||
// first with nothing at all: a NULL never equals '', so a filter written that
|
||||
// way returns zero rows and looks broken rather than wrong. Asking it in
|
||||
// plain words is not a mistake worth punishing.
|
||||
if strings.TrimSpace(v) == "" {
|
||||
switch c.Op {
|
||||
case "eq":
|
||||
c.Op = "empty"
|
||||
case "ne":
|
||||
c.Op = "notempty"
|
||||
}
|
||||
}
|
||||
switch c.Op {
|
||||
case "eq":
|
||||
return col + " = ?", []any{v}, nil
|
||||
@@ -1416,8 +1439,18 @@ func conditionSQL(c Condition) (string, []any, error) {
|
||||
}
|
||||
return col + " IN (" + ph + ")", args, nil
|
||||
case "empty":
|
||||
// A numeric column is empty when it is NULL *or* zero, and that has to be
|
||||
// said explicitly: SQLite compares 0 against '' as false while MySQL calls
|
||||
// it true, so IFNULL(col,'')='' quietly means different things on the two
|
||||
// backends OpsLog supports.
|
||||
if numericColumns[strings.ToLower(strings.TrimSpace(c.Field))] {
|
||||
return "(" + col + " IS NULL OR " + col + " = 0)", nil, nil
|
||||
}
|
||||
return "IFNULL(" + col + ",'') = ''", nil, nil
|
||||
case "notempty":
|
||||
if numericColumns[strings.ToLower(strings.TrimSpace(c.Field))] {
|
||||
return "(" + col + " IS NOT NULL AND " + col + " <> 0)", nil, nil
|
||||
}
|
||||
return "IFNULL(" + col + ",'') <> ''", nil, nil
|
||||
default:
|
||||
return "", nil, fmt.Errorf("unknown operator %q", c.Op)
|
||||
|
||||
Reference in New Issue
Block a user