Typed columns and null #13

Merged
lilleman merged 10 commits from typed-columns into main 2026-09-15 15:19:39 +02:00
8 changed files with 83 additions and 58 deletions
Showing only changes of commit 3e7127da71 - Show all commits
+5 -4
View File
@@ -273,8 +273,8 @@ Writes e.g. `{"id":1,"paid":true,"total":59.97}`. A column is a field of the top
template, or an item of a choice standing in for one; `datatype` anywhere else is a template, or an item of a choice standing in for one; `datatype` anywhere else is a
load error. A typed column holds one value, alone in its format: a literal, one load error. A typed column holds one value, alone in its format: a literal, one
`{int()}`, `{float()}`, `{seq()}` or `{calc()}` call, or a read that lands only on such `{int()}`, `{float()}`, `{seq()}` or `{calc()}` call, or a read that lands only on such
values. `integer` is an int64 written `-?(0|[1-9][0-9]*)` — `{float()}` prints one at values. `integer` is an int64 written `0|-?[1-9][0-9]*` — `{float()}` prints one at
`0` decimals — `number` a JSON number, `boolean` `true` or `false`. A value its `0` decimals within int64 — `number` a JSON number, `boolean` `true` or `false`. A value its
datatype cannot hold is a load error naming it: datatype cannot hold is a load error naming it:
```text ```text
@@ -287,7 +287,7 @@ literal, an `{int()}`, `{float()}`, `{seq()}` or `{digits()}` call, a calc, or a
such values, whose bounds keep every divisor from zero and the result within `1e300`. such values, whose bounds keep every divisor from zero and the result within `1e300`.
What the bounds cannot show is refused — `{calc(a / b)}: divides by b, which is not What the bounds cannot show is refused — `{calc(a / b)}: divides by b, which is not
proven nonzero`. The calc fills an `integer` column at `0` decimals, or over whole proven nonzero`. The calc fills an `integer` column at `0` decimals, or over whole
operands with no `/`. operands with no `/`, while its bounds stay within int64.
### Null ### Null
@@ -365,7 +365,8 @@ hyphenated field can't be an operand.
{ "format": "{net} x {qty} = {calc(net * qty, 2)}", "net": ["19.99", "5.00"], "qty": ["3", "7"] } { "format": "{net} x {qty} = {calc(net * qty, 2)}", "net": ["19.99", "5.00"], "qty": ["3", "7"] }
``` ```
Renders e.g. `19.99 x 3 = 59.97`. An operand that can never be a number (`"abc"`, Renders e.g. `19.99 x 3 = 59.97`. A result that rounds to zero prints unsigned — `0`,
`0.00` — as `{float()}`'s does. An operand that can never be a number (`"abc"`,
or a choice of such) is rejected at load, as is a division by a constant zero or a choice of such) is rejected at load, as is a division by a constant zero
(`1/0`, or a fixed `"0"` field); an operand that sometimes is not a number yields (`1/0`, or a fixed `"0"` field); an operand that sometimes is not a number yields
`NaN`, and a division by one that is not constant `Inf` — both print rather than `NaN`, and a division by one that is not constant `Inf` — both print rather than
+18 -9
View File
@@ -31,8 +31,8 @@ var builtins = map[string]builtin{
"ulid": {arity: 0, prep: sample(ulid)}, "ulid": {arity: 0, prep: sample(ulid)},
"nanoid": {arity: 1, check: posIntArg, prep: chars(nanoidAlphabet)}, "nanoid": {arity: 1, check: posIntArg, prep: chars(nanoidAlphabet)},
"hex": {arity: 1, check: posIntArg, prep: chars(hexDigits)}, "hex": {arity: 1, check: posIntArg, prep: chars(hexDigits)},
"digits": {arity: 1, check: posIntArg, prep: chars("0123456789"), number: func(a []string) (proven, DataType) { "digits": {arity: 1, check: posIntArg, prep: chars("0123456789"), number: func(token string, a []string) proven {
return bounded(0, math.Pow(10, float64(atoi(a[0])))-1, true), DataTypeString return printing(token, DataTypeString, bounded(0, math.Pow(10, float64(atoi(a[0])))-1, true))
}}, }},
"upper": {arity: 1, check: posIntArg, prep: chars("ABCDEFGHIJKLMNOPQRSTUVWXYZ")}, "upper": {arity: 1, check: posIntArg, prep: chars("ABCDEFGHIJKLMNOPQRSTUVWXYZ")},
"lower": {arity: 1, check: posIntArg, prep: chars("abcdefghijklmnopqrstuvwxyz")}, "lower": {arity: 1, check: posIntArg, prep: chars("abcdefghijklmnopqrstuvwxyz")},
@@ -45,16 +45,16 @@ var builtins = map[string]builtin{
"int": {arity: 2, check: intRangeArgs, prep: func(a []string) callFn { "int": {arity: 2, check: intRangeArgs, prep: func(a []string) callFn {
lo, span := atoi(a[0]), atoi(a[1])-atoi(a[0])+1 lo, span := atoi(a[0]), atoi(a[1])-atoi(a[0])+1
return func(s *session, _ string, _ []string) string { return strconv.Itoa(lo + s.IntN(span)) } return func(s *session, _ string, _ []string) string { return strconv.Itoa(lo + s.IntN(span)) }
}, number: func(a []string) (proven, DataType) { }, number: func(token string, a []string) proven {
return bounded(float64(atoi(a[0])), float64(atoi(a[1])), true), DataTypeInteger return printing(token, DataTypeInteger, bounded(float64(atoi(a[0])), float64(atoi(a[1])), true))
}}, }},
"float": {arity: 3, check: floatArgs, prep: func(a []string) callFn { "float": {arity: 3, check: floatArgs, prep: func(a []string) callFn {
lo, hi, dp := atof(a[0]), atof(a[1]), atoi(a[2]) lo, hi, dp := atof(a[0]), atof(a[1]), atoi(a[2])
return func(s *session, _ string, _ []string) string { return func(s *session, _ string, _ []string) string {
return strconv.FormatFloat(lo+s.Float64()*(hi-lo), 'f', dp, 64) return formatFloat(lo+s.Float64()*(hi-lo), dp)
} }
}, number: func(a []string) (proven, DataType) { }, number: func(token string, a []string) proven {
return printedNumber(bounded(atof(a[0]), atof(a[1]), false), atoi(a[2])) return printedNumber(token, bounded(atof(a[0]), atof(a[1]), false), atoi(a[2]))
}}, }},
"iban": {arity: 1, check: ibanArg, prep: func(a []string) callFn { "iban": {arity: 1, check: ibanArg, prep: func(a []string) callFn {
cc := a[0] cc := a[0]
@@ -75,8 +75,8 @@ var builtins = map[string]builtin{
return func(s *session, _ string, _ []string) string { return func(s *session, _ string, _ []string) string {
return strconv.FormatUint(s.next(key), 10) return strconv.FormatUint(s.next(key), 10)
} }
}, number: func([]string) (proven, DataType) { }, number: func(token string, _ []string) proven {
return bounded(1, math.MaxInt64, true), DataTypeInteger return printing(token, DataTypeInteger, bounded(1, math.MaxInt64, true))
}}, }},
} }
@@ -104,6 +104,15 @@ func chars(alphabet string) func([]string) callFn {
const hexDigits = "0123456789abcdef" const hexDigits = "0123456789abcdef"
// formatFloat prints v to dp decimals, -1 for the shortest form, and a zero unsigned.
func formatFloat(v float64, dp int) string {
s := strconv.FormatFloat(v, 'f', dp, 64)
if strings.HasPrefix(s, "-") && strings.Trim(s, "-0.") == "" {
return s[1:]
}
return s
}
// transforms are the builtins that rewrite one operand's value; they nest, so // transforms are the builtins that rewrite one operand's value; they nest, so
// {lowercase(ascii(x))} folds then lowers. // {lowercase(ascii(x))} folds then lowers.
var transforms = map[string]func(string) string{ var transforms = map[string]func(string) string{
+1 -1
View File
@@ -195,7 +195,7 @@ func calcPrep(args []string) callFn {
placed := indexVars(expr, at) placed := indexVars(expr, at)
dp := calcDecimals(args) dp := calcDecimals(args)
return func(_ *session, _ string, operands []string) string { return func(_ *session, _ string, operands []string) string {
return strconv.FormatFloat(placed.eval(operands), 'f', dp, 64) return formatFloat(placed.eval(operands), dp)
} }
} }
+1 -1
View File
@@ -100,7 +100,7 @@ func disagreement(a, b *template) error {
switch { switch {
case bare.datatype != DataTypeString: case bare.datatype != DataTypeString:
return fmt.Errorf("its items declare %s and %s; a column holds one datatype", a.datatype, b.datatype) return fmt.Errorf("its items declare %s and %s; a column holds one datatype", a.datatype, b.datatype)
case len(bare.fields) == 0 && bare.repeat == 1: case bare.fields == nil: // a JSON string; an object, which may carry a weight, has a fields map
return fmt.Errorf(`item %q declares no datatype, and a column holds one; write it as {"format":%q,"datatype":%q}`, bare.format, bare.format, typed.datatype) return fmt.Errorf(`item %q declares no datatype, and a column holds one; write it as {"format":%q,"datatype":%q}`, bare.format, bare.format, typed.datatype)
} }
return fmt.Errorf(`an item declares no datatype beside one declaring %s; a column holds one, so give it "datatype": %q`, typed.datatype, typed.datatype) return fmt.Errorf(`an item declares no datatype beside one declaring %s; a column holds one, so give it "datatype": %q`, typed.datatype, typed.datatype)
+4 -2
View File
@@ -32,8 +32,7 @@ type choice struct {
func (*choice) isNode() {} func (*choice) isNode() {}
// null is a record column's missing value, rendered as "". It is not zero-sized, so two // null is a column's missing value, rendered ""; sized so two nulls are two map keys.
// nulls are two map keys.
type null struct{ _ byte } type null struct{ _ byte }
func (*null) isNode() {} func (*null) isNode() {}
@@ -216,6 +215,9 @@ func checkNoRepeatedItem(items []any) error {
if s, isString := raw.(string); isString { if s, isString := raw.(string); isString {
return fmt.Errorf("choice item %q is repeated; skew the odds with a weight instead: { \"format\": %q, \"weight\": 2 }", s, s) return fmt.Errorf("choice item %q is repeated; skew the odds with a weight instead: { \"format\": %q, \"weight\": 2 }", s, s)
} }
if raw == nil {
return fmt.Errorf("choice item %d repeats null; a null takes no weight, so weight the other items instead", i)
}
return fmt.Errorf("choice item %d repeats item %d; skew the odds with a weight on one of them instead", i, j) return fmt.Errorf("choice item %d repeats item %d; skew the odds with a weight on one of them instead", i, j)
} }
seen[string(key)] = i seen[string(key)] = i
+10 -10
View File
@@ -1,12 +1,13 @@
package fejkdata package fejkdata
import ( import (
"encoding/csv"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"sort" "sort"
"strings" "strings"
"unicode"
"unicode/utf8"
) )
// Column is one rendered column of a record. Value is the rendered text, which a // Column is one rendered column of a record. Value is the rendered text, which a
@@ -73,15 +74,16 @@ func (r *Record) CSVLine() string {
return strings.Join(fields, ",") return strings.Join(fields, ",")
} }
// csvField quotes a field where encoding/csv would, and an empty one too.
func csvField(s string) string { func csvField(s string) string {
if s == "" { first, _ := utf8.DecodeRuneInString(s)
switch {
case s == "":
return `""` return `""`
case s == `\.` || strings.ContainsAny(s, "\",\r\n") || unicode.IsSpace(first):
return `"` + strings.ReplaceAll(s, `"`, `""`) + `"`
} }
var b strings.Builder return s
w := csv.NewWriter(&b)
_ = w.Write([]string{s})
w.Flush()
return strings.TrimSuffix(b.String(), "\n")
} }
// SQLInsert renders the record as one INSERT statement into table, identifiers in ANSI // SQLInsert renders the record as one INSERT statement into table, identifiers in ANSI
@@ -104,9 +106,7 @@ func quoteIdent(s string) string {
return `"` + strings.ReplaceAll(s, `"`, `""`) + `"` return `"` + strings.ReplaceAll(s, `"`, `""`) + `"`
} }
// literal spells a column the way a serializer writes it: quoted for a string, bare for // literal writes a string column quoted, a proven typed one bare, and a null as nullText.
// any other datatype, whose every render the load check proved a literal, and nullText
// for a null.
func literal(c Column, quote func(string) string, nullText string) string { func literal(c Column, quote func(string) string, nullText string) string {
switch { switch {
case c.Null: case c.Null:
+3 -3
View File
@@ -72,9 +72,9 @@ type builtin struct {
// operands names the fields the call reads, which expand renders for it; nil // operands names the fields the call reads, which expand renders for it; nil
// for a builtin that reads none. // for a builtin that reads none.
operands func(args []string) []string operands func(args []string) []string
// number bounds the number a call prints and names the datatype its text is; nil // number proves what a call prints, token its body: the bounds of its number and the
// for a builtin whose text is no number. // datatype of its text; nil for a builtin whose text is no number.
number func(args []string) (proven, DataType) number func(token string, args []string) proven
} }
// funcCall splits a "{token}" body shaped name(args) into its parts; ok is false // funcCall splits a "{token}" body shaped name(args) into its parts; ok is false
+37 -24
View File
@@ -14,13 +14,11 @@ type proven struct {
lo, hi float64 lo, hi float64
nonZero float64 // every value is at least this far from zero; 0 when one can be zero nonZero float64 // every value is at least this far from zero; 0 when one can be zero
integral bool integral bool
notNumber string // why some render reads as no finite number, the way calc reads it notOperand string // why some render reads as no finite number, the way calc reads it
not [len(dataTypeNames)]string not [len(dataTypeNames)]string
} }
// valueProof proves what typed columns and their calc operands hold, each node once per // valueProof proves what typed columns and their calc operands hold, each node once per scope.
// scope. A typed column holds one value: a literal, one value builtin, one calc, or a
// read of such values.
type valueProof struct { type valueProof struct {
memo map[node]proven memo map[node]proven
} }
@@ -71,8 +69,8 @@ func (p *valueProof) unite(nodes []node) proven {
w := p.of(n) w := p.of(n)
v.lo, v.hi, v.nonZero = min(v.lo, w.lo), max(v.hi, w.hi), min(v.nonZero, w.nonZero) v.lo, v.hi, v.nonZero = min(v.lo, w.lo), max(v.hi, w.hi), min(v.nonZero, w.nonZero)
v.integral = v.integral && w.integral v.integral = v.integral && w.integral
if v.notNumber == "" { if v.notOperand == "" {
v.notNumber = w.notNumber v.notOperand = w.notOperand
} }
for d := range v.not { for d := range v.not {
if v.not[d] == "" { if v.not[d] == "" {
@@ -93,7 +91,7 @@ func (p *valueProof) template(t *template) proven {
return literalValue(t.lit) return literalValue(t.lit)
case len(t.ops) != 1: case len(t.ops) != 1:
v := unproven(notOneValue(t.format, "{int()}, {float()}, {seq()} or {calc()}")) v := unproven(notOneValue(t.format, "{int()}, {float()}, {seq()} or {calc()}"))
v.notNumber = notOneValue(t.format, "{int()}, {float()}, {seq()}, {digits()} or {calc()}") v.notOperand = notOneValue(t.format, "{int()}, {float()}, {seq()}, {digits()} or {calc()}")
return v return v
} }
body := t.format[1 : len(t.format)-1] body := t.format[1 : len(t.format)-1]
@@ -108,12 +106,11 @@ func (p *valueProof) template(t *template) proven {
case name == "calc": case name == "calc":
return p.calc(t, body, args) return p.calc(t, body, args)
case builtins[name].number != nil: case builtins[name].number != nil:
v, prints := builtins[name].number(args) return builtins[name].number(body, args)
return printing(body, prints, v)
case isTransform: case isTransform:
return unproven(fmt.Sprintf("{%s} rewrites text rather than printing a value; write the values it would print", body)) return unproven(fmt.Sprintf("{%s} rewrites text rather than printing a value; write the values it would print", body))
} }
return printing(body, DataTypeString, proven{notNumber: fmt.Sprintf("{%s} prints text, not a number", body)}) return printing(body, DataTypeString, proven{notOperand: fmt.Sprintf("{%s} prints text, not a number", body)})
} }
func (p *valueProof) calc(t *template, body string, args []string) proven { func (p *valueProof) calc(t *template, body string, args []string) proven {
@@ -128,8 +125,7 @@ func (p *valueProof) calc(t *template, body string, args []string) proven {
if doubt != "" { if doubt != "" {
return unproven(fmt.Sprintf("{%s}: %s", body, doubt)) return unproven(fmt.Sprintf("{%s}: %s", body, doubt))
} }
v, prints := printedNumber(v, calcDecimals(args)) return printedNumber(body, v, calcDecimals(args))
return printing(body, prints, v)
} }
// calcLimit is the largest magnitude a proof accepts as finite, far enough below // calcLimit is the largest magnitude a proof accepts as finite, far enough below
@@ -144,8 +140,8 @@ func (p *valueProof) expr(n calcNode, fields map[string]node) (proven, string) {
return bounded(v, v, v == math.Trunc(v)), "" return bounded(v, v, v == math.Trunc(v)), ""
case calcVar: case calcVar:
v := p.of(fields[string(n)]) v := p.of(fields[string(n)])
if v.notNumber != "" { if v.notOperand != "" {
return proven{}, fmt.Sprintf("operand %q: %s", string(n), v.notNumber) return proven{}, fmt.Sprintf("operand %q: %s", string(n), v.notOperand)
} }
return proven{lo: v.lo, hi: v.hi, nonZero: v.nonZero, integral: v.integral}, "" return proven{lo: v.lo, hi: v.hi, nonZero: v.nonZero, integral: v.integral}, ""
case calcNeg: case calcNeg:
@@ -205,17 +201,21 @@ func bounded(lo, hi float64, integral bool) proven {
func magnitude(v proven) float64 { return math.Max(math.Abs(v.lo), math.Abs(v.hi)) } func magnitude(v proven) float64 { return math.Max(math.Abs(v.lo), math.Abs(v.hi)) }
// printedNumber is v once strconv.FormatFloat prints it to dp decimals, and the datatype // printedNumber is what a token printing v to dp decimals holds: an integer when whole
// that text is: an integer when whole and within int64, else a number. // and within int64, else a number.
func printedNumber(v proven, dp int) (proven, DataType) { func printedNumber(token string, v proven, dp int) proven {
if dp >= 0 { if dp >= 0 {
half := math.Pow(10, -float64(dp)) / 2 half, _ := strconv.ParseFloat("5e-"+strconv.Itoa(dp+1), 64) // math.Pow(10, -dp) can land below the tie and let a printed zero through
v = proven{lo: v.lo - half, hi: v.hi + half, nonZero: math.Max(0, v.nonZero-half), integral: v.integral || dp == 0} v = proven{lo: v.lo - half, hi: v.hi + half, nonZero: math.Max(0, v.nonZero-half), integral: v.integral || dp == 0}
} }
if (dp == 0 || dp < 0 && v.integral) && magnitude(v) < math.MaxInt64 { if dp != 0 && !(dp < 0 && v.integral) {
return v, DataTypeInteger return printing(token, DataTypeNumber, v)
} }
return v, DataTypeNumber v = printing(token, DataTypeInteger, v)
if !(magnitude(v) < math.MaxInt64) {
v.not[DataTypeInteger] = fmt.Sprintf("{%s} is not proven within int64", token)
}
return v
} }
// printing is v for a token whose every render is text of datatype prints, with a reason // printing is v for a token whose every render is text of datatype prints, with a reason
@@ -235,7 +235,7 @@ func notOneValue(format, calls string) string {
// unproven is a render no datatype and no calc can take, for why. // unproven is a render no datatype and no calc can take, for why.
func unproven(why string) proven { func unproven(why string) proven {
v := proven{notNumber: why} v := proven{notOperand: why}
for d := DataTypeInteger; d <= DataTypeBoolean; d++ { for d := DataTypeInteger; d <= DataTypeBoolean; d++ {
v.not[d] = why v.not[d] = why
} }
@@ -251,7 +251,7 @@ var (
func literalValue(text string) proven { func literalValue(text string) proven {
var v proven var v proven
if f, err := strconv.ParseFloat(strings.TrimSpace(text), 64); err != nil || math.IsNaN(f) || math.IsInf(f, 0) { if f, err := strconv.ParseFloat(strings.TrimSpace(text), 64); err != nil || math.IsNaN(f) || math.IsInf(f, 0) {
v.notNumber = fmt.Sprintf("%q is not a number", text) v.notOperand = fmt.Sprintf("%q is not a number", text)
} else { } else {
v = bounded(f, f, f == math.Trunc(f)) v = bounded(f, f, f == math.Trunc(f))
} }
@@ -260,11 +260,24 @@ func literalValue(text string) proven {
} else if err != nil { } else if err != nil {
v.not[DataTypeInteger] = fmt.Sprintf("%q is past the int64 range", text) v.not[DataTypeInteger] = fmt.Sprintf("%q is past the int64 range", text)
} }
if v.notNumber != "" || !numberText.MatchString(text) { if v.notOperand != "" || !numberText.MatchString(text) {
v.not[DataTypeNumber] = fmt.Sprintf("%q is not a number", text) v.not[DataTypeNumber] = fmt.Sprintf("%q is not a number", text)
} }
if text != "true" && text != "false" { if text != "true" && text != "false" {
v.not[DataTypeBoolean] = fmt.Sprintf("%q is not a boolean", text) v.not[DataTypeBoolean] = fmt.Sprintf("%q is not a boolean", text)
} }
return signedZero(text, v)
}
// signedZero refuses a zero written with a sign as a typed value, naming it unsigned.
func signedZero(text string, v proven) proven {
if !strings.HasPrefix(text, "-") || v.notOperand != "" || v.lo != 0 {
return v
}
for _, d := range []DataType{DataTypeInteger, DataTypeNumber} {
if v.not[d] == "" {
v.not[d] = fmt.Sprintf("%q is zero written with a sign; write %q", text, text[1:])
}
}
return v return v
} }