Skip to content

Files people exchange

The format a spreadsheet writes, read and written without holding the whole file.

CSV

Tags, embedded structs, per-field time formats, and the BOM that decides whether Excel shows Thai or mojibake — plus the streaming pair for an export whose size is the database's business rather than the process's. See CSV.

go
package main

import (
	"bytes"
	"io"
	"net/http"
	"time"

	core "gitlab.finema.co/finema/idin-core/v2"
)

// --- Example 4: CSV ---------------------------------------------------------
//
// Generic marshal/unmarshal over encoding/csv. Columns come from the `csv:"name"`
// tag, falling back to the field name; `csv:"-"` drops a field.
//
// A field of a type with no textual form (a slice, a map, a struct that is not
// time.Time) is an *error* rather than an empty column — a report that quietly
// lost a value is worse than one that failed to build.

// Timestamps is embedded without a name, so it contributes its own columns
// rather than one column named after the type. The embedded type must be
// exported: reflection cannot read through an unexported one without tripping
// over its own safety rules. Give the field a `csv:"name"` tag to keep it as a
// single column instead.
type Timestamps struct {
	CreatedAt time.Time  `csv:"created_at"`
	UpdatedAt *time.Time `csv:"updated_at"` // nil writes an empty cell
}

// SalesRow is one line of the export. `format:` overrides the file's TimeLayout
// for one column, and is used again when the file is read back.
type SalesRow struct {
	Timestamps
	Day    time.Time `csv:"day,format:2006-01-02"`
	Name   string    `csv:"name"`
	Amount float64   `csv:"amount"`
	Paid   bool      `csv:"paid"`
	Note   *string   `csv:"note"` // nil → empty cell
	Secret string    `csv:"-"`    // never leaves the process
}

// exportSmall builds a whole file in memory. Fine for a report whose size you
// control; see exportStreaming for one whose size is the database's business.
func exportSmall(rows []SalesRow) ([]byte, core.IError) {
	var buf bytes.Buffer
	// BOM: Excel decides a file's encoding from the byte-order mark. Without it,
	// it reads in the machine's code page and every Thai character becomes
	// mojibake. It is off by default because a file another *program* parses
	// should not begin with a mark it does not recognise — turn it on only when
	// the reader is a person with Excel.
	if err := core.CSVMarshal(&buf, rows, core.CSVOptions{BOM: true}); err != nil {
		return nil, err
	}
	return buf.Bytes(), nil
}

// exportHandler writes straight to the response. Nothing is buffered here
// either, so the client starts receiving while the rows are still being read.
func exportHandler(c core.IHTTPContext, next func() ([]SalesRow, core.IError)) error {
	c.Response().Header().Set("Content-Type", "text/csv; charset=utf-8")
	c.Response().Header().Set("Content-Disposition", `attachment; filename="sales.csv"`)
	return exportStreaming(c.Response(), next)
}

// exportStreaming holds one batch in memory at a time.
//
// NewCSVWriter writes the BOM and the header immediately, so an export that
// turns out to have no rows still produces a valid file with its columns — an
// empty CSV and a broken CSV look identical to whoever opens it otherwise.
func exportStreaming(w io.Writer, next func() ([]SalesRow, core.IError)) core.IError {
	cw, err := core.NewCSVWriter[SalesRow](w, core.CSVOptions{BOM: true})
	if err != nil {
		return err
	}

	for {
		batch, bErr := next()
		if bErr != nil {
			return bErr
		}
		if len(batch) == 0 {
			break
		}
		if wErr := cw.Write(batch...); wErr != nil {
			return wErr
		}
	}

	// nothing is guaranteed to have reached w until Flush, and Flush reports the
	// first error of the whole file — so it is never safe to skip
	return cw.Flush()
}

// importAll reads the whole file into a slice. Columns are matched by header
// against the tags, a heading the struct does not know is skipped (and the
// columns after it still line up), and a leading BOM is ignored — so a file
// exported from Excel loads straight back.
func importAll(r io.Reader) ([]SalesRow, core.IError) {
	return core.CSVUnmarshal[SalesRow](r)
}

// importStreaming holds one row at a time, which is what an upload whose size
// you do not control requires. An error from the callback stops the read
// immediately and comes back out.
//
// An empty cell leaves the field at its zero value rather than failing, because
// that is exactly what a spreadsheet writes for "no value".
func importStreaming(ctx core.IContext, r io.Reader) core.IError {
	var imported int
	err := core.CSVEach(r, func(row SalesRow) error {
		if row.Name == "" {
			// returning here abandons the rest of the file — for an import where
			// partial success is acceptable, collect the bad rows instead and
			// report them all at once
			return core.New(http.StatusBadRequest, "INVALID_ROW", "name is required")
		}
		imported++
		return nil
	})
	if err != nil {
		return err
	}
	ctx.Log().Info("imported rows", "count", imported)
	return nil
}

// pagedRows is the shape a real export feeds exportStreaming with: a closure
// over a repository query that returns the next page and finally nothing.
// Keeping it a callback is what lets the writer stay ignorant of where rows come
// from — a table, a Mongo cursor, an upstream API.
func pagedRows(all []SalesRow, size int) func() ([]SalesRow, core.IError) {
	offset := 0
	return func() ([]SalesRow, core.IError) {
		if offset >= len(all) {
			return nil, nil
		}
		end := min(offset+size, len(all))
		batch := all[offset:end]
		offset = end
		return batch, nil
	}
}

Maintained by Passakon Puttasuwan & Dev Core Team.