Typed columns and null: a datatype option, null items, and a load check that every typed render parses
Tests / vet + fmt + tests (pull_request) Successful in 58s

This commit is contained in:
2026-09-15 11:23:57 +02:00
parent 044294dd90
commit 94dc562c6c
13 changed files with 1106 additions and 107 deletions
+81 -44
View File
@@ -9,10 +9,14 @@ import (
"strings"
)
// Column is one rendered column of a record.
// Column is one rendered column of a record. Value is the rendered text, which a
// serializer quotes for DataTypeString and writes bare for any other datatype; a Null
// column has no Value.
type Column struct {
Name string
Value string
Name string
DataType DataType
Value string
Null bool
}
// Record is one record rendered from a template: every direct field is a column,
@@ -28,70 +32,93 @@ func (r *Record) Columns() []Column {
return append([]Column(nil), r.columns...)
}
// JSON renders the record as one JSON object, every column a string.
// JSON renders the record as one JSON object.
func (r *Record) JSON() string {
m := make(map[string]string, len(r.columns))
for _, c := range r.columns {
m[c.Name] = c.Value
var b strings.Builder
b.WriteByte('{')
for i, c := range r.columns {
if i > 0 {
b.WriteByte(',')
}
b.WriteString(jsonString(c.Name))
b.WriteByte(':')
b.WriteString(literal(c, jsonString, "null"))
}
b, _ := json.Marshal(m)
b.WriteByte('}')
return b.String()
}
func jsonString(s string) string {
b, _ := json.Marshal(s)
return string(b)
}
// CSVHeader renders the column names as one CSV header line.
func (r *Record) CSVHeader() string {
return csvLine(r.names())
fields := make([]string, len(r.columns))
for i, c := range r.columns {
fields[i] = csvField(c.Name)
}
return strings.Join(fields, ",")
}
// CSVLine renders the column values as one CSV row.
// CSVLine renders the column values as one CSV row: a null column an empty field and an
// empty string "", the convention PostgreSQL's COPY reads a null by.
func (r *Record) CSVLine() string {
return csvLine(r.values())
}
func (r *Record) names() []string {
out := make([]string, len(r.columns))
fields := make([]string, len(r.columns))
for i, c := range r.columns {
out[i] = c.Name
fields[i] = literal(c, csvField, "")
}
return out
if line := strings.Join(fields, ","); line != "" {
return line
}
return `""` // a blank line is a row every CSV reader drops
}
func (r *Record) values() []string {
out := make([]string, len(r.columns))
for i, c := range r.columns {
out[i] = c.Value
func csvField(s string) string {
if s == "" {
return `""`
}
return out
}
func csvLine(cols []string) string {
var b strings.Builder
w := csv.NewWriter(&b)
_ = w.Write(cols)
_ = w.Write([]string{s})
w.Flush()
line := strings.TrimSuffix(b.String(), "\n")
if line == "" {
return `""` // a blank line is a row every CSV reader drops
}
return line
return strings.TrimSuffix(b.String(), "\n")
}
// SQLInsert renders the record as one INSERT statement into table: identifiers in
// ANSI double quotes, every value a single-quoted string literal.
// SQLInsert renders the record as one INSERT statement into table, identifiers in ANSI
// double quotes.
func (r *Record) SQLInsert(table string) string {
cols := make([]string, len(r.columns))
vals := make([]string, len(r.columns))
for i, c := range r.columns {
cols[i] = quoteIdent(c.Name)
vals[i] = "'" + strings.ReplaceAll(c.Value, "'", "''") + "'"
vals[i] = literal(c, sqlString, "NULL")
}
return fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s);", quoteIdent(table), strings.Join(cols, ", "), strings.Join(vals, ", "))
}
func sqlString(s string) string {
return "'" + strings.ReplaceAll(s, "'", "''") + "'"
}
func quoteIdent(s string) string {
return `"` + strings.ReplaceAll(s, `"`, `""`) + `"`
}
// literal spells a column the way a serializer writes it: quoted for a string, bare for
// 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 {
switch {
case c.Null:
return nullText
case c.DataType == DataTypeString:
return quote(c.Value)
}
return c.Value
}
// FakeRecord renders a path as one record: the template it names, with each direct
// field drawn as a column. Only a category-level template is a record — a path
// that descends into a field, or that names a folder or a choice, is an error.
@@ -116,7 +143,7 @@ func (f *Generator) FakeRecord(path string) (*Record, error) {
// columns, or why it is not a record.
type recordShape struct {
t *template
columns []string
columns []Column
err error
}
@@ -140,7 +167,7 @@ func (f *Generator) recordShapeOf(n node) recordShape {
type RecordTemplate struct {
g *Generator
t *template
columns []string
columns []Column
}
// Fake renders the record with one draw.
@@ -175,7 +202,7 @@ func (f *Generator) FakeRecordTemplate(input string) (*Record, error) {
// recordOf is the fence both record entry points pass. The columns come back with
// the template, fixed for every draw the caller goes on to make.
func recordOf(n node) (*template, []string, error) {
func recordOf(n node) (*template, []Column, error) {
t, ok := n.(*template)
if !ok {
return nil, nil, errors.New("names a choice, not a template; a record is a template whose fields are its columns")
@@ -183,13 +210,18 @@ func recordOf(n node) (*template, []string, error) {
if t.repeat != 1 {
return nil, nil, fmt.Errorf("carries repeat %d, which composes its format into one string; a record projects columns instead — drop the repeat and render the record again for more rows", t.repeat)
}
columns := recordColumns(t)
if len(columns) == 0 {
names := recordColumns(t)
if len(names) == 0 {
return nil, nil, errors.New("has no fields, so no columns")
}
if err := checkColumnRefs(t, columns); err != nil {
if err := checkColumnRefs(t, names); err != nil {
return nil, nil, err
}
columns := make([]Column, len(names))
for i, name := range names {
datatype, _ := columnDatatype(t.fields[name]) // compile refused a column whose items disagree
columns[i] = Column{Name: name, DataType: datatype}
}
return t, columns, nil
}
@@ -262,11 +294,16 @@ func columnRefs(t *template, columns []string) ([]columnRef, error) {
// renderRecord draws each column once, in the name order recordOf fixed, over one
// reference scope shared across them.
func renderRecord(s *session, t *template, columns []string) *Record {
func renderRecord(s *session, t *template, columns []Column) *Record {
scope := &draws{variant: map[string]node{}, value: map[string]string{}}
r := &Record{columns: make([]Column, len(columns))}
for i, name := range columns {
r.columns[i] = Column{Name: name, Value: render(s, t.fields[name], scope)}
r := &Record{columns: append([]Column(nil), columns...)}
for i := range r.columns {
n := drawn(s, t.fields[r.columns[i].Name])
if _, isNull := n.(*null); isNull {
r.columns[i].Null = true
} else {
r.columns[i].Value = render(s, n, scope)
}
}
return r
}