From 48674c1d53e1098cf60c3f3f6781ed8f677e8388 Mon Sep 17 00:00:00 2001 From: XuShuo Date: Mon, 20 Apr 2026 19:12:45 +0800 Subject: [PATCH 1/7] feat: add read APIs for Excel files to models and maps with strict mode support --- read.go | 533 +++++++++++++++++++++++++++++++++++++++++++++ read_bench_test.go | 87 ++++++++ read_test.go | 206 ++++++++++++++++++ readme.md | 48 ++++ 4 files changed, 874 insertions(+) create mode 100644 read.go create mode 100644 read_bench_test.go create mode 100644 read_test.go diff --git a/read.go b/read.go new file mode 100644 index 0000000..b0eb52f --- /dev/null +++ b/read.go @@ -0,0 +1,533 @@ +package excelorm + +import ( + "bytes" + "errors" + "fmt" + "reflect" + "strconv" + "strings" + "sync" + "time" + + "github.com/xuri/excelize/v2" +) + +type ReadOption func(*readOptions) + +type readOptions struct { + timeFormatLayout string + strict bool + ifNullValue *string + trueValue *string + falseValue *string +} + +var timeType = reflect.TypeOf(time.Time{}) + +var readModelMetaCache sync.Map // map[reflect.Type]*readModelMeta + +type readValueKind int + +const ( + readValueUnsupported readValueKind = iota + readValueString + readValueBool + readValueInt + readValueUint + readValueFloat + readValueTime +) + +type readFieldMeta struct { + index int + value readValueKind + bits int + ptr bool + goType reflect.Type + elemTyp reflect.Type +} + +type readModelMeta struct { + sheetName string + headers []string + fieldByHeader map[string]readFieldMeta + headerSet map[string]struct{} +} + +type readColumnBinding struct { + colIndex int + header string + field readFieldMeta +} + +// WithReadTimeFormatLayout sets the parsing layout used for time values. +func WithReadTimeFormatLayout(layout string) ReadOption { + return func(options *readOptions) { + options.timeFormatLayout = layout + } +} + +// WithReadStrictMode enables strict header validation. +func WithReadStrictMode() ReadOption { + return func(options *readOptions) { + options.strict = true + } +} + +// WithReadIfNullValue maps a specific cell value to nil pointer fields. +func WithReadIfNullValue(value string) ReadOption { + return func(options *readOptions) { + options.ifNullValue = &value + } +} + +// WithReadBoolValueAs configures custom true/false input values. +func WithReadBoolValueAs(trueValue, falseValue string) ReadOption { + return func(options *readOptions) { + options.trueValue = &trueValue + options.falseValue = &falseValue + } +} + +// ReadExcelToModels reads one sheet into a model slice. +// out must be a pointer to a slice whose element type is struct or *struct implementing SheetModel. +func ReadExcelToModels(fileName string, out any, opts ...ReadOption) error { + if fileName == "" { + return errors.New("fileName can not be empty") + } + f, err := excelize.OpenFile(fileName) + if err != nil { + return err + } + readErr := readModelsFromExcelFile(f, out, opts...) + closeErr := f.Close() + return errors.Join(readErr, closeErr) +} + +// ReadExcelToModelsFromBytesBuffer reads one sheet into a model slice from a buffer. +func ReadExcelToModelsFromBytesBuffer(buffer *bytes.Buffer, out any, opts ...ReadOption) error { + if buffer == nil { + return errors.New("buffer can not be nil") + } + f, err := excelize.OpenReader(bytes.NewReader(buffer.Bytes())) + if err != nil { + return err + } + readErr := readModelsFromExcelFile(f, out, opts...) + closeErr := f.Close() + return errors.Join(readErr, closeErr) +} + +// ReadExcelToMaps reads one sheet into []map[header]cellValue. +func ReadExcelToMaps(fileName, sheetName string, opts ...ReadOption) ([]map[string]string, error) { + if fileName == "" { + return nil, errors.New("fileName can not be empty") + } + if sheetName == "" { + return nil, errors.New("sheetName can not be empty") + } + f, err := excelize.OpenFile(fileName) + if err != nil { + return nil, err + } + maps, readErr := readMapsFromExcelFile(f, sheetName, opts...) + closeErr := f.Close() + if err = errors.Join(readErr, closeErr); err != nil { + return nil, err + } + return maps, nil +} + +// ReadExcelToMapsFromBytesBuffer reads one sheet into []map[header]cellValue from a buffer. +func ReadExcelToMapsFromBytesBuffer(buffer *bytes.Buffer, sheetName string, opts ...ReadOption) ([]map[string]string, error) { + if buffer == nil { + return nil, errors.New("buffer can not be nil") + } + if sheetName == "" { + return nil, errors.New("sheetName can not be empty") + } + f, err := excelize.OpenReader(bytes.NewReader(buffer.Bytes())) + if err != nil { + return nil, err + } + maps, readErr := readMapsFromExcelFile(f, sheetName, opts...) + closeErr := f.Close() + if err = errors.Join(readErr, closeErr); err != nil { + return nil, err + } + return maps, nil +} + +func readModelsFromExcelFile(f *excelize.File, out any, opts ...ReadOption) error { + options := defaultReadOptions() + for _, opt := range opts { + opt(options) + } + + sliceValue, elemType, isPointerElem, err := targetSliceMeta(out) + if err != nil { + return err + } + meta, err := getReadModelMeta(elemType) + if err != nil { + return err + } + rows, err := f.GetRows(meta.sheetName) + if err != nil { + return err + } + if len(rows) == 0 { + sliceValue.Set(reflect.MakeSlice(sliceValue.Type(), 0, 0)) + return nil + } + + headers := rows[0] + if options.strict { + if err = validateHeaderRow(headers); err != nil { + return err + } + if err = validateStrictModelHeaders(headers, meta); err != nil { + return err + } + } + + bindings := buildReadColumnBindings(headers, meta) + if len(bindings) == 0 { + sliceValue.Set(reflect.MakeSlice(sliceValue.Type(), 0, 0)) + return nil + } + + result := reflect.MakeSlice(sliceValue.Type(), 0, len(rows)-1) + for rowIndex := 1; rowIndex < len(rows); rowIndex++ { + row := rows[rowIndex] + if isEmptyRow(row) { + continue + } + + modelValue := reflect.New(elemType).Elem() + for _, binding := range bindings { + raw := safeCellValue(row, binding.colIndex) + if err = assignStringByMeta(modelValue.Field(binding.field.index), raw, binding.field, options); err != nil { + return fmt.Errorf("sheet %q row %d col %d (%s): %w", meta.sheetName, rowIndex+1, binding.colIndex+1, binding.header, err) + } + } + + if isPointerElem { + rowValue := reflect.New(elemType) + rowValue.Elem().Set(modelValue) + result = reflect.Append(result, rowValue) + } else { + result = reflect.Append(result, modelValue) + } + } + + sliceValue.Set(result) + return nil +} + +func readMapsFromExcelFile(f *excelize.File, sheetName string, opts ...ReadOption) ([]map[string]string, error) { + options := defaultReadOptions() + for _, opt := range opts { + opt(options) + } + + rows, err := f.GetRows(sheetName) + if err != nil { + return nil, err + } + if len(rows) == 0 { + return []map[string]string{}, nil + } + + headers := rows[0] + if options.strict { + if err = validateHeaderRow(headers); err != nil { + return nil, err + } + } + + maps := make([]map[string]string, 0, len(rows)-1) + for rowIndex := 1; rowIndex < len(rows); rowIndex++ { + row := rows[rowIndex] + if isEmptyRow(row) { + continue + } + item := make(map[string]string, len(headers)) + for colIndex, header := range headers { + if header == "" { + continue + } + item[header] = safeCellValue(row, colIndex) + } + maps = append(maps, item) + } + return maps, nil +} + +func defaultReadOptions() *readOptions { + return &readOptions{timeFormatLayout: "2006-01-02 15:04:05"} +} + +func targetSliceMeta(out any) (reflect.Value, reflect.Type, bool, error) { + if out == nil { + return reflect.Value{}, nil, false, errors.New("out can not be nil") + } + outValue := reflect.ValueOf(out) + if outValue.Kind() != reflect.Ptr || outValue.IsNil() { + return reflect.Value{}, nil, false, errors.New("out must be a non-nil pointer to slice") + } + sliceValue := outValue.Elem() + if sliceValue.Kind() != reflect.Slice { + return reflect.Value{}, nil, false, errors.New("out must be a pointer to slice") + } + + elemType := sliceValue.Type().Elem() + isPointerElem := false + if elemType.Kind() == reflect.Ptr { + isPointerElem = true + elemType = elemType.Elem() + } + if elemType.Kind() != reflect.Struct { + return reflect.Value{}, nil, false, errors.New("slice element must be struct or *struct") + } + return sliceValue, elemType, isPointerElem, nil +} + +func getReadModelMeta(modelType reflect.Type) (*readModelMeta, error) { + if cached, ok := readModelMetaCache.Load(modelType); ok { + return cached.(*readModelMeta), nil + } + + sheetName, err := sheetNameFromType(modelType) + if err != nil { + return nil, err + } + + meta := &readModelMeta{ + sheetName: sheetName, + headers: make([]string, 0, modelType.NumField()), + fieldByHeader: make(map[string]readFieldMeta, modelType.NumField()), + headerSet: make(map[string]struct{}, modelType.NumField()), + } + for i := 0; i < modelType.NumField(); i++ { + field := modelType.Field(i) + if field.PkgPath != "" { // skip unexported fields + continue + } + header := field.Tag.Get("excelorm") + if header == "" { + header = field.Tag.Get("excel_header") + } + if header == "-" { + continue + } + if header == "" { + header = field.Name + } + if _, exists := meta.fieldByHeader[header]; exists { + return nil, fmt.Errorf("duplicated model header %q", header) + } + fieldMeta := resolveReadFieldMeta(i, field.Type) + meta.headers = append(meta.headers, header) + meta.fieldByHeader[header] = fieldMeta + meta.headerSet[header] = struct{}{} + } + + stored, _ := readModelMetaCache.LoadOrStore(modelType, meta) + return stored.(*readModelMeta), nil +} + +func sheetNameFromType(modelType reflect.Type) (string, error) { + if modelType.Kind() != reflect.Struct { + return "", errors.New("sheetModel must be struct") + } + if model, ok := reflect.New(modelType).Interface().(SheetModel); ok { + sheetName := model.SheetName() + if sheetName == "" { + return "", errors.New("sheetModel must have a sheet name") + } + return sheetName, nil + } + if model, ok := reflect.New(modelType).Elem().Interface().(SheetModel); ok { + sheetName := model.SheetName() + if sheetName == "" { + return "", errors.New("sheetModel must have a sheet name") + } + return sheetName, nil + } + return "", errors.New("slice element must implement SheetModel") +} + +func resolveReadFieldMeta(index int, typ reflect.Type) readFieldMeta { + meta := readFieldMeta{index: index, goType: typ, elemTyp: typ} + if typ.Kind() == reflect.Pointer { + meta.ptr = true + meta.elemTyp = typ.Elem() + } + switch meta.elemTyp.Kind() { + case reflect.String: + meta.value = readValueString + case reflect.Bool: + meta.value = readValueBool + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + meta.value = readValueInt + meta.bits = meta.elemTyp.Bits() + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + meta.value = readValueUint + meta.bits = meta.elemTyp.Bits() + case reflect.Float32, reflect.Float64: + meta.value = readValueFloat + meta.bits = meta.elemTyp.Bits() + case reflect.Struct: + if meta.elemTyp == timeType { + meta.value = readValueTime + break + } + meta.value = readValueUnsupported + default: + meta.value = readValueUnsupported + } + return meta +} + +func validateStrictModelHeaders(headers []string, meta *readModelMeta) error { + headerSet := make(map[string]struct{}, len(headers)) + for _, header := range headers { + headerSet[header] = struct{}{} + if _, ok := meta.fieldByHeader[header]; !ok { + return fmt.Errorf("strict mode: header %q has no matching field", header) + } + } + for _, header := range meta.headers { + if _, ok := headerSet[header]; !ok { + return fmt.Errorf("strict mode: model field header %q is missing in sheet", header) + } + } + return nil +} + +func buildReadColumnBindings(headers []string, meta *readModelMeta) []readColumnBinding { + bindings := make([]readColumnBinding, 0, len(headers)) + for colIndex, header := range headers { + if header == "" { + continue + } + fieldMeta, ok := meta.fieldByHeader[header] + if !ok { + continue + } + bindings = append(bindings, readColumnBinding{colIndex: colIndex, header: header, field: fieldMeta}) + } + return bindings +} + +func validateHeaderRow(headers []string) error { + if len(headers) == 0 { + return errors.New("strict mode: header row is empty") + } + seen := make(map[string]int, len(headers)) + for i, header := range headers { + if header == "" { + return fmt.Errorf("strict mode: empty header at column %d", i+1) + } + if firstIndex, exists := seen[header]; exists { + return fmt.Errorf("strict mode: duplicated header %q at columns %d and %d", header, firstIndex+1, i+1) + } + seen[header] = i + } + return nil +} + +func assignStringByMeta(fieldValue reflect.Value, raw string, fieldMeta readFieldMeta, options *readOptions) error { + if fieldMeta.ptr { + if raw == "" || (options.ifNullValue != nil && raw == *options.ifNullValue) { + fieldValue.Set(reflect.Zero(fieldMeta.goType)) + return nil + } + target := reflect.New(fieldMeta.elemTyp) + if err := assignPrimitiveByMeta(target.Elem(), raw, fieldMeta, options); err != nil { + return err + } + fieldValue.Set(target) + return nil + } + return assignPrimitiveByMeta(fieldValue, raw, fieldMeta, options) +} + +func assignPrimitiveByMeta(fieldValue reflect.Value, raw string, fieldMeta readFieldMeta, options *readOptions) error { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + fieldValue.Set(reflect.Zero(fieldMeta.elemTyp)) + return nil + } + + switch fieldMeta.value { + case readValueString: + fieldValue.SetString(raw) + return nil + case readValueBool: + value, err := parseBool(trimmed, options) + if err != nil { + return err + } + fieldValue.SetBool(value) + return nil + case readValueInt: + value, err := strconv.ParseInt(trimmed, 10, fieldMeta.bits) + if err != nil { + return err + } + fieldValue.SetInt(value) + return nil + case readValueUint: + value, err := strconv.ParseUint(trimmed, 10, fieldMeta.bits) + if err != nil { + return err + } + fieldValue.SetUint(value) + return nil + case readValueFloat: + value, err := strconv.ParseFloat(trimmed, fieldMeta.bits) + if err != nil { + return err + } + fieldValue.SetFloat(value) + return nil + case readValueTime: + parsedAt, err := time.Parse(options.timeFormatLayout, trimmed) + if err != nil { + return err + } + fieldValue.Set(reflect.ValueOf(parsedAt)) + return nil + default: + return fmt.Errorf("unsupported type %s", fieldMeta.elemTyp) + } +} + +func parseBool(value string, options *readOptions) (bool, error) { + if options.trueValue != nil && value == *options.trueValue { + return true, nil + } + if options.falseValue != nil && value == *options.falseValue { + return false, nil + } + return strconv.ParseBool(strings.ToLower(value)) +} + +func safeCellValue(row []string, index int) string { + if index >= len(row) { + return "" + } + return row[index] +} + +func isEmptyRow(row []string) bool { + for _, cell := range row { + if strings.TrimSpace(cell) != "" { + return false + } + } + return true +} diff --git a/read_bench_test.go b/read_bench_test.go new file mode 100644 index 0000000..888e0a3 --- /dev/null +++ b/read_bench_test.go @@ -0,0 +1,87 @@ +package excelorm + +import "testing" + +func BenchmarkReadExcelToModelsFromBytesBuffer(b *testing.B) { + buffer, err := WriteExcelAsBytesBuffer(benchmarkModels(1000, false, false)) + if err != nil { + b.Fatalf("prepare benchmark buffer failed: %v", err) + } + + b.Run("rows_1000", func(b *testing.B) { + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + var rows []Sheet1 + if err := ReadExcelToModelsFromBytesBuffer(buffer, &rows); err != nil { + b.Fatalf("ReadExcelToModelsFromBytesBuffer failed: %v", err) + } + if len(rows) == 0 { + b.Fatal("empty rows") + } + } + }) + + b.Run("rows_1000_strict", func(b *testing.B) { + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + var rows []Sheet1 + if err := ReadExcelToModelsFromBytesBuffer(buffer, &rows, WithReadStrictMode()); err != nil { + b.Fatalf("ReadExcelToModelsFromBytesBuffer strict failed: %v", err) + } + if len(rows) == 0 { + b.Fatal("empty rows") + } + } + }) + + b.Run("rows_1000_pointer_slice", func(b *testing.B) { + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + var rows []*Sheet1 + if err := ReadExcelToModelsFromBytesBuffer(buffer, &rows); err != nil { + b.Fatalf("ReadExcelToModelsFromBytesBuffer pointer slice failed: %v", err) + } + if len(rows) == 0 { + b.Fatal("empty rows") + } + } + }) +} + +func BenchmarkReadExcelToMapsFromBytesBuffer(b *testing.B) { + buffer, err := WriteExcelAsBytesBuffer(benchmarkModels(1000, false, false)) + if err != nil { + b.Fatalf("prepare benchmark buffer failed: %v", err) + } + + b.Run("rows_1000", func(b *testing.B) { + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + rows, err := ReadExcelToMapsFromBytesBuffer(buffer, "sheet_one") + if err != nil { + b.Fatalf("ReadExcelToMapsFromBytesBuffer failed: %v", err) + } + if len(rows) == 0 { + b.Fatal("empty rows") + } + } + }) + + b.Run("rows_1000_strict", func(b *testing.B) { + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + rows, err := ReadExcelToMapsFromBytesBuffer(buffer, "sheet_one", WithReadStrictMode()) + if err != nil { + b.Fatalf("ReadExcelToMapsFromBytesBuffer strict failed: %v", err) + } + if len(rows) == 0 { + b.Fatal("empty rows") + } + } + }) +} diff --git a/read_test.go b/read_test.go new file mode 100644 index 0000000..7b9cda2 --- /dev/null +++ b/read_test.go @@ -0,0 +1,206 @@ +package excelorm + +import ( + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" + "github.com/xuri/excelize/v2" +) + +type ReadCustomSheet struct { + Col1 string `excel_header:"string"` + Col4 bool `excel_header:"bool"` + Col5 time.Time `excel_header:"time"` + Col6 *string `excel_header:"string pointer"` + Col7 *int `excel_header:"int pointer"` + Col8 *float64 `excel_header:"float pointer"` + Col9 *bool `excel_header:"bool pointer"` + Col10 *time.Time `excel_header:"time pointer"` +} + +func (ReadCustomSheet) SheetName() string { + return "sheet_one" +} + +func TestReadExcelToModels(t *testing.T) { + path := writeReadFixture(t) + + var rows []Sheet1 + err := ReadExcelToModels(path, &rows) + require.NoError(t, err) + require.Len(t, rows, 1) + require.Equal(t, "string", rows[0].Col1) + require.Equal(t, 1, rows[0].Col2) + require.Equal(t, 1.1, rows[0].Col3) + require.True(t, rows[0].Col4) + require.Equal(t, time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC), rows[0].Col5) + require.Nil(t, rows[0].Col6) +} + +func TestReadExcelToModels_PointerSlice(t *testing.T) { + path := writeReadFixture(t) + + var rows []*Sheet1 + err := ReadExcelToModels(path, &rows) + require.NoError(t, err) + require.Len(t, rows, 1) + require.NotNil(t, rows[0]) + require.Equal(t, "string", rows[0].Col1) +} + +func TestReadExcelToModelsFromBytesBuffer(t *testing.T) { + buffer, err := WriteExcelAsBytesBuffer(baseModels(false)) + require.NoError(t, err) + + var rows []Sheet2 + err = ReadExcelToModelsFromBytesBuffer(buffer, &rows) + require.NoError(t, err) + require.Len(t, rows, 1) + require.Equal(t, "string", rows[0].Col1) + require.Equal(t, "string_value", *rows[0].Col6) +} + +func TestReadExcelToMaps(t *testing.T) { + path := writeReadFixture(t) + rows, err := ReadExcelToMaps(path, "sheet_one") + require.NoError(t, err) + require.Len(t, rows, 1) + require.Equal(t, "string", rows[0]["string"]) + require.Equal(t, "1", rows[0]["int"]) +} + +func TestReadExcelToMapsFromBytesBuffer(t *testing.T) { + buffer, err := WriteExcelAsBytesBuffer(baseModels(false)) + require.NoError(t, err) + + rows, err := ReadExcelToMapsFromBytesBuffer(buffer, "sheet_one") + require.NoError(t, err) + require.Len(t, rows, 1) + require.Equal(t, "string", rows[0]["string"]) +} + +func TestReadExcelToModels_StrictMode(t *testing.T) { + t.Run("rejects unknown header", func(t *testing.T) { + path := writeCustomSheet(t, "sheet_one", [][]string{ + {"string", "unknown"}, + {"v", "x"}, + }) + + var rows []Sheet1 + err := ReadExcelToModels(path, &rows, WithReadStrictMode()) + require.EqualError(t, err, "strict mode: header \"unknown\" has no matching field") + }) + + t.Run("rejects missing model header", func(t *testing.T) { + path := writeCustomSheet(t, "sheet_one", [][]string{ + {"string"}, + {"v"}, + }) + + var rows []Sheet1 + err := ReadExcelToModels(path, &rows, WithReadStrictMode()) + require.Error(t, err) + require.ErrorContains(t, err, "strict mode: model field header") + require.ErrorContains(t, err, "is missing in sheet") + }) + + t.Run("rejects duplicated headers", func(t *testing.T) { + path := writeCustomSheet(t, "sheet_one", [][]string{ + {"string", "string"}, + {"v1", "v2"}, + }) + + var rows []Sheet1 + err := ReadExcelToModels(path, &rows, WithReadStrictMode()) + require.EqualError(t, err, "strict mode: duplicated header \"string\" at columns 1 and 2") + }) +} + +func TestReadExcelToModels_CustomOptions(t *testing.T) { + path := writeCustomSheet(t, "sheet_one", [][]string{ + {"string", "bool", "time", "string pointer", "int pointer", "float pointer", "bool pointer", "time pointer"}, + {"a", "yes", "2026/01/02 03:04:05", "-", "-", "-", "-", "-"}, + }) + + var rows []ReadCustomSheet + err := ReadExcelToModels(path, &rows, + WithReadStrictMode(), + WithReadTimeFormatLayout("2006/01/02 15:04:05"), + WithReadBoolValueAs("yes", "no"), + WithReadIfNullValue("-"), + ) + require.NoError(t, err) + require.Len(t, rows, 1) + require.Equal(t, "a", rows[0].Col1) + require.True(t, rows[0].Col4) + require.Equal(t, time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC), rows[0].Col5) + require.Nil(t, rows[0].Col6) + require.Nil(t, rows[0].Col7) + require.Nil(t, rows[0].Col8) + require.Nil(t, rows[0].Col9) + require.Nil(t, rows[0].Col10) +} + +func TestReadExcelToModels_Errors(t *testing.T) { + t.Run("invalid out", func(t *testing.T) { + path := writeReadFixture(t) + var rows []Sheet1 + err := ReadExcelToModels(path, rows) + require.EqualError(t, err, "out must be a non-nil pointer to slice") + }) + + t.Run("invalid element", func(t *testing.T) { + path := writeReadFixture(t) + var rows []int + err := ReadExcelToModels(path, &rows) + require.EqualError(t, err, "slice element must be struct or *struct") + }) + + t.Run("sheet model missing method", func(t *testing.T) { + path := writeReadFixture(t) + var rows []struct{ A string } + err := ReadExcelToModels(path, &rows) + require.EqualError(t, err, "slice element must implement SheetModel") + }) + + t.Run("map api requires sheet name", func(t *testing.T) { + path := writeReadFixture(t) + _, err := ReadExcelToMaps(path, "") + require.EqualError(t, err, "sheetName can not be empty") + }) +} + +func writeReadFixture(t *testing.T) string { + t.Helper() + path := filepath.Join(t.TempDir(), "read_fixture.xlsx") + err := WriteExcelSaveAs(path, baseModels(false)) + require.NoError(t, err) + return path +} + +func writeCustomSheet(t *testing.T, sheetName string, rows [][]string) string { + t.Helper() + f := excelize.NewFile() + t.Cleanup(func() { + require.NoError(t, f.Close()) + }) + + _, err := f.NewSheet(sheetName) + require.NoError(t, err) + for rowIndex, row := range rows { + values := make([]any, len(row)) + for i, value := range row { + values[i] = value + } + cell, err := coordinatesToCellName(1, rowIndex+1) + require.NoError(t, err) + require.NoError(t, f.SetSheetRow(sheetName, cell, &values)) + } + require.NoError(t, f.DeleteSheet("Sheet1")) + + path := filepath.Join(t.TempDir(), "custom.xlsx") + require.NoError(t, f.SaveAs(path)) + return path +} diff --git a/readme.md b/readme.md index ca8443d..383c690 100644 --- a/readme.md +++ b/readme.md @@ -63,3 +63,51 @@ if err := excelorm.WriteExcelSaveAs("foo.xlsx", sheetModels, [foo.xlsx](foo.xlsx) * To support multiple sheets, define more structs that implement `SheetName`. + +## Read APIs + +### Read into models + +Use `ReadExcelToModels` to parse one sheet into a typed slice. The target must be a pointer to slice (`*[]T` or `*[]*T`), and `T` must implement `SheetName`. + +```go +var rows []Foo +if err := excelorm.ReadExcelToModels("foo.xlsx", &rows); err != nil { + log.Fatal(err) +} +``` + +### Read into maps + +Use `ReadExcelToMaps` to read a sheet as `[]map[string]string`. + +```go +rows, err := excelorm.ReadExcelToMaps("foo.xlsx", "foo sheet name") +if err != nil { + log.Fatal(err) +} +fmt.Println(rows[0]["id"]) +``` + +### Strict mode + +Strict mode validates header consistency: + +- Rejects empty headers. +- Rejects duplicated headers. +- Rejects headers in Excel that do not map to model fields. +- Rejects model fields that are missing in Excel. + +```go +var rows []Foo +if err := excelorm.ReadExcelToModels("foo.xlsx", &rows, excelorm.WithReadStrictMode()); err != nil { + log.Fatal(err) +} +``` + +### Read options + +- `WithReadTimeFormatLayout(layout)` for parsing `time.Time`. +- `WithReadBoolValueAs(trueValue, falseValue)` for custom bool values. +- `WithReadIfNullValue(value)` to map marker values to nil pointers. + From 14310b3ea1d65241329e5fa03473875dedb3950c Mon Sep 17 00:00:00 2001 From: XuShuo Date: Mon, 20 Apr 2026 19:16:17 +0800 Subject: [PATCH 2/7] refa: rename file build => write --- build.go => write.go | 0 build_bench_test.go => write_bench_test.go | 0 build_test.go => write_test.go | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename build.go => write.go (100%) rename build_bench_test.go => write_bench_test.go (100%) rename build_test.go => write_test.go (100%) diff --git a/build.go b/write.go similarity index 100% rename from build.go rename to write.go diff --git a/build_bench_test.go b/write_bench_test.go similarity index 100% rename from build_bench_test.go rename to write_bench_test.go diff --git a/build_test.go b/write_test.go similarity index 100% rename from build_test.go rename to write_test.go From 0f5b7c1e8c27c944d0c88895bbbf7e4a1ea2ed64 Mon Sep 17 00:00:00 2001 From: XuShuo Date: Mon, 20 Apr 2026 19:19:10 +0800 Subject: [PATCH 3/7] chore: add copyright notice to read and test files --- read.go | 19 +++++++++++++++++++ read_bench_test.go | 19 +++++++++++++++++++ read_test.go | 19 +++++++++++++++++++ write_bench_test.go | 19 +++++++++++++++++++ 4 files changed, 76 insertions(+) diff --git a/read.go b/read.go index b0eb52f..b17c3af 100644 --- a/read.go +++ b/read.go @@ -1,3 +1,22 @@ +// Copyright (c) 2026 Varus Hsu +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +// the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + package excelorm import ( diff --git a/read_bench_test.go b/read_bench_test.go index 888e0a3..708f926 100644 --- a/read_bench_test.go +++ b/read_bench_test.go @@ -1,3 +1,22 @@ +// Copyright (c) 2026 Varus Hsu +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +// the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + package excelorm import "testing" diff --git a/read_test.go b/read_test.go index 7b9cda2..94aa312 100644 --- a/read_test.go +++ b/read_test.go @@ -1,3 +1,22 @@ +// Copyright (c) 2026 Varus Hsu +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +// the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + package excelorm import ( diff --git a/write_bench_test.go b/write_bench_test.go index eaf2b86..7dd783d 100644 --- a/write_bench_test.go +++ b/write_bench_test.go @@ -1,3 +1,22 @@ +// Copyright (c) 2026 Varus Hsu +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +// the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + package excelorm import ( From 6a98d121159748be21eb29e8851b14d826532c4c Mon Sep 17 00:00:00 2001 From: XuShuo Date: Thu, 30 Apr 2026 01:14:44 +0800 Subject: [PATCH 4/7] refa: remove redundant sheet name validation in read.go --- read.go | 7 ------- 1 file changed, 7 deletions(-) diff --git a/read.go b/read.go index b17c3af..619918c 100644 --- a/read.go +++ b/read.go @@ -368,13 +368,6 @@ func sheetNameFromType(modelType reflect.Type) (string, error) { } return sheetName, nil } - if model, ok := reflect.New(modelType).Elem().Interface().(SheetModel); ok { - sheetName := model.SheetName() - if sheetName == "" { - return "", errors.New("sheetModel must have a sheet name") - } - return sheetName, nil - } return "", errors.New("slice element must implement SheetModel") } From 0e0c18cced253d9b362543ca5a31ba3f785f6df0 Mon Sep 17 00:00:00 2001 From: XuShuo Date: Thu, 30 Apr 2026 01:17:52 +0800 Subject: [PATCH 5/7] fix: preserve original string in read.go to retain leading/trailing spaces --- read.go | 1 + 1 file changed, 1 insertion(+) diff --git a/read.go b/read.go index 619918c..8cffe91 100644 --- a/read.go +++ b/read.go @@ -476,6 +476,7 @@ func assignPrimitiveByMeta(fieldValue reflect.Value, raw string, fieldMeta readF switch fieldMeta.value { case readValueString: + // Preserve original string without trimming, as users may want to keep leading/trailing spaces fieldValue.SetString(raw) return nil case readValueBool: From 0f4d99cb1e21e061bcf8fc5f5ba38883ee5387ef Mon Sep 17 00:00:00 2001 From: XuShuo Date: Thu, 30 Apr 2026 01:31:24 +0800 Subject: [PATCH 6/7] test: add tests for reading Excel files with header-only sheets --- read_test.go | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/read_test.go b/read_test.go index 94aa312..98687f5 100644 --- a/read_test.go +++ b/read_test.go @@ -100,6 +100,27 @@ func TestReadExcelToMapsFromBytesBuffer(t *testing.T) { require.Equal(t, "string", rows[0]["string"]) } +func TestReadExcelToModels_HeaderOnlySheet(t *testing.T) { + path := writeCustomSheet(t, "sheet_one", [][]string{ + {"string", "int", "float", "bool", "time", "string pointer", "int pointer", "float pointer", "bool pointer", "time pointer"}, + }) + + var rows []Sheet1 + err := ReadExcelToModels(path, &rows) + require.NoError(t, err) + require.Len(t, rows, 0) +} + +func TestReadExcelToMaps_HeaderOnlySheet(t *testing.T) { + path := writeCustomSheet(t, "sheet_one", [][]string{ + {"string", "int", "float", "bool", "time", "string pointer", "int pointer", "float pointer", "bool pointer", "time pointer"}, + }) + + rows, err := ReadExcelToMaps(path, "sheet_one") + require.NoError(t, err) + require.Len(t, rows, 0) +} + func TestReadExcelToModels_StrictMode(t *testing.T) { t.Run("rejects unknown header", func(t *testing.T) { path := writeCustomSheet(t, "sheet_one", [][]string{ From b70a9e3fe5705724ef60725ce23f1b7971e9e7f8 Mon Sep 17 00:00:00 2001 From: XuShuo Date: Thu, 30 Apr 2026 01:36:37 +0800 Subject: [PATCH 7/7] test: update assertions to use require.Empty for clarity in read_test.go --- read_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/read_test.go b/read_test.go index 98687f5..c3a6d9f 100644 --- a/read_test.go +++ b/read_test.go @@ -52,7 +52,7 @@ func TestReadExcelToModels(t *testing.T) { require.Len(t, rows, 1) require.Equal(t, "string", rows[0].Col1) require.Equal(t, 1, rows[0].Col2) - require.Equal(t, 1.1, rows[0].Col3) + require.InDelta(t, 1.1, rows[0].Col3, 1e-9) require.True(t, rows[0].Col4) require.Equal(t, time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC), rows[0].Col5) require.Nil(t, rows[0].Col6) @@ -108,7 +108,7 @@ func TestReadExcelToModels_HeaderOnlySheet(t *testing.T) { var rows []Sheet1 err := ReadExcelToModels(path, &rows) require.NoError(t, err) - require.Len(t, rows, 0) + require.Empty(t, rows) } func TestReadExcelToMaps_HeaderOnlySheet(t *testing.T) { @@ -118,7 +118,7 @@ func TestReadExcelToMaps_HeaderOnlySheet(t *testing.T) { rows, err := ReadExcelToMaps(path, "sheet_one") require.NoError(t, err) - require.Len(t, rows, 0) + require.Empty(t, rows) } func TestReadExcelToModels_StrictMode(t *testing.T) {