Repository: tableauio/tableau (go)

Issue:
confgen(table): report error if duplicate column names are found

Pull request:
confgen(table): add duplicate column name check
- close #206

Patch:
diff --git a/internal/confgen/parser.go b/internal/confgen/parser.go
index e3e57e9..48f91f7 100644
--- a/internal/confgen/parser.go
+++ b/internal/confgen/parser.go
@@ -250,10 +250,10 @@ type sheetParser struct {
 	sheetOpts    *tableaupb.WorksheetOptions
 	extInfo      *SheetParserExtInfo
 
-	// cached name and type
+	// cached names and types
 	names       []string               // names[col] -> name
 	types       []string               // types[col] -> name
-	lookupTable book.ColumnLookupTable // name -> column index (started with 0)
+	lookupTable book.ColumnLookupTable // column name -> column index (started with 0)
 }
 
 // SheetParserExtInfo is the extended info for refer check and so on.
@@ -279,7 +279,7 @@ func NewExtendedSheetParser(protoPackage, locationName string, strcaseCtx strcas
 		bookOpts:     bookOpts,
 		sheetOpts:    sheetOpts,
 		extInfo:      extInfo,
-		lookupTable:  map[string]uint32{},
+		lookupTable:  book.ColumnLookupTable{},
 	}
 }
 
diff --git a/internal/confgen/table_parser.go b/internal/confgen/table_parser.go
index 07acd70..7ec88e1 100644
--- a/internal/confgen/table_parser.go
+++ b/internal/confgen/table_parser.go
@@ -5,6 +5,7 @@ import (
 	"strconv"
 
 	"github.com/tableauio/tableau/internal/confgen/fieldprop"
+	"github.com/tableauio/tableau/internal/excel"
 	"github.com/tableauio/tableau/internal/importer/book"
 	"github.com/tableauio/tableau/internal/protogen/parseroptions"
 	"github.com/tableauio/tableau/internal/types"
@@ -58,7 +59,11 @@ func (sp *tableParser) Parse(protomsg proto.Message, sheet *book.Sheet) error {
 					return xerrors.WrapKV(err)
 				}
 				curr.NewCell(row, &sp.names[row], &sp.types[row], data, sp.sheetOpts.AdjacentKey)
-				sp.lookupTable[sp.names[row]] = uint32(row)
+				name := sp.names[row]
+				if foundRow, ok := sp.lookupTable[name]; ok && foundRow != row {
+					return xerrors.E0003(name, excel.Postion(foundRow, nameCol), excel.Postion(row, nameCol))
+				}
+				sp.lookupTable[name] = row
 			}
 			curr.SetColumnLookupTable(sp.lookupTable)
 
@@ -105,7 +110,11 @@ func (sp *tableParser) Parse(protomsg proto.Message, sheet *book.Sheet) error {
 					return xerrors.WrapKV(err)
 				}
 				curr.NewCell(col, &sp.names[col], &sp.types[col], data, sp.sheetOpts.AdjacentKey)
-				sp.lookupTable[sp.names[col]] = uint32(col)
+				name := sp.names[col]
+				if foundCol, ok := sp.lookupTable[name]; ok && foundCol != col {
+					return xerrors.E0003(name, excel.Postion(nameRow, foundCol), excel.Postion(nameRow, col))
+				}
+				sp.lookupTable[name] = col
 			}
 
 			curr.SetColumnLookupTable(sp.lookupTable)
diff --git a/internal/excel/excel.go b/internal/excel/excel.go
index 523c30c..9071fcf 100644
--- a/internal/excel/excel.go
+++ b/internal/excel/excel.go
@@ -10,7 +10,7 @@ import (
 	"github.com/xuri/excelize/v2"
 )
 
-// LetterAxis generate the corresponding column name.
+// LetterAxis generate the corresponding column letter position.
 // index: 0-based.
 func LetterAxis(index int) string {
 	var (
@@ -24,8 +24,9 @@ func LetterAxis(index int) string {
 	return colCode + string(key+int32(index)%26)
 }
 
-// Postion generate the position in a sheet.
-// row and col both are 0-based.
+// Postion generate the position (e.g.: A1) in a sheet.
+//
+// NOTE: row and col both are 0-based.
 func Postion(row, col int) string {
 	return fmt.Sprintf("%s%d", LetterAxis(col), row+1)
 }
diff --git a/internal/importer/book/cell.go b/internal/importer/book/cell.go
index 4f564a9..a69520e 100644
--- a/internal/importer/book/cell.go
+++ b/internal/importer/book/cell.go
@@ -54,9 +54,9 @@ type RowCells struct {
 	SheetName string
 	prev      *RowCells
 
-	Row         int                 // row number
-	cells       map[uint32]*RowCell // column index (started with 0) -> RowCell
-	lookupTable ColumnLookupTable   // name -> column index
+	Row         int               // row number
+	cells       map[int]*RowCell  // column index (started with 0) -> RowCell
+	lookupTable ColumnLookupTable // name -> column index
 }
 
 func NewRowCells(row int, prev *RowCells, sheetName string) *RowCells {
@@ -65,7 +65,7 @@ func NewRowCells(row int, prev *RowCells, sheetName string) *RowCells {
 		prev:      prev,
 
 		Row:   row,
-		cells: make(map[uint32]*RowCell),
+		cells: make(map[int]*RowCell),
 	}
 }
 
@@ -155,7 +155,7 @@ func (r *RowCells) findCellRangeWithNamePrefix(prefix string) (left, right *RowC
 	if minCol == -1 || maxCol == -1 {
 		return nil, nil
 	}
-	return r.cells[uint32(minCol)], r.cells[uint32(maxCol)]
+	return r.cells[minCol], r.cells[maxCol]
 }
 
 func (r *RowCells) CellDebugKV(name string) []any {
@@ -186,7 +186,7 @@ func (r *RowCells) CellDebugKV(name string) []any {
 }
 
 // column name -> column index (started with 0)
-type ColumnLookupTable = map[string]uint32
+type ColumnLookupTable = map[string]int
 
 func (r *RowCells) SetColumnLookupTable(table ColumnLookupTable) {
 	r.lookupTable = table
@@ -212,13 +212,12 @@ func (r *RowCells) NewCell(col int, name, typ *string, data string, needPopulate
 			} else {
 				for i := cell.Col - 1; i >= 0; i-- {
 					// prevData := r.prev.cells[col].Data
-					ui := uint32(i)
-					backCell := r.cells[ui]
+					backCell := r.cells[i]
 					if !strings.HasPrefix(backCell.GetName(), prefix) {
 						break
 					}
 					if types.IsMap(backCell.GetType()) || types.IsKeyedList(backCell.GetType()) {
-						if r.prev.cells[ui].Data == r.cells[ui].Data {
+						if r.prev.cells[i].Data == r.cells[i].Data {
 							needPopulate = true
 							break
 						}
@@ -238,8 +237,7 @@ func (r *RowCells) NewCell(col int, name, typ *string, data string, needPopulate
 	}
 
 	// add new cell
-	index := uint32(col)
-	r.cells[index] = cell
+	r.cells[col] = cell
 }
 
 func (r *RowCells) GetCellCountWithPrefix(prefix string) int {
diff --git a/internal/localizer/i18n/config/ecode/en.yaml b/internal/localizer/i18n/config/ecode/en.yaml
index 7f3ffd8..34a6b1d 100644
--- a/internal/localizer/i18n/config/ecode/en.yaml
+++ b/internal/localizer/i18n/config/ecode/en.yaml
@@ -6,11 +6,11 @@ E0002:
   desc: cannot unmarshal file content to given proto.Message
   text: 'failed to unmarshal file "{{.Filename}}" to message "{{.MessageName}}": {{.Error}}{{.Lines}}'
   help: check the file content format is correct
-# [1000, 1999]: protogen error
-E1000:
-  desc: column name conflicts in name row
-  text: found same name "{{.Name}}" in both "{{.Position1}}" and "{{.Position2}}"
+E0003:
+  desc: duplicate column name
+  text: duplicate column name "{{.Name}}" in both "{{.Position1}}" and "{{.Position2}}"
   help: rename column name and keep sure it is unique in name row
+# [1000, 1999]: protogen error
 # [2000, 2999]: confgen error
 E2000:
   desc: integer overflow
diff --git a/internal/localizer/i18n/config/ecode/zh.yaml b/internal/localizer/i18n/config/ecode/zh.yaml
index a999403..c2dd768 100644
--- a/internal/localizer/i18n/config/ecode/zh.yaml
+++ b/internal/localizer/i18n/config/ecode/zh.yaml
@@ -6,11 +6,11 @@ E0002:
   desc: cannot unmarshal file content to given proto.Message
   text: '无法将"{{.Filename}}"的文件内容反序列化到Protobuf消息"{{.MessageName}}": {{.Error}}{{.Lines}}'
   help: 检查文件内容格式是否正确
-# [1000, 1999]: protogen error
-E1000:
-  desc: column name conflicts in name row
+E0003:
+  desc: duplicate column name
   text: '"{{.Position1}}" 和 "{{.Position2}}" 存在相同的列名 "{{.Name}}"'
   help: 修改冲突的列名，以确保唯一性
+# [1000, 1999]: protogen error
 # [2000, 2999]: confgen error
 E2000:
   desc: integer overflow
@@ -54,7 +54,7 @@ E2009:
   help: "合表时，不同的表不允许配置重复的键"
 E2010:
   desc: union type has no corresponding value field
-  text: 'union 类型"{{.TypeValue}}"找不到 field number 为"{{.FieldNumber}}"的对应 value 字段'
+  text: 'union 类型 "{{.TypeValue}}" 找不到 field number 为 "{{.FieldNumber}}" 的对应 value 字段'
   help: '新增 union value field 并绑定到类型"{{.TypeValue}}"'
 E2011:
   desc: field presence required but cell not filled
diff --git a/internal/protogen/table_header.go b/internal/protogen/table_header.go
index 5263f52..d31742b 100644
--- a/internal/protogen/table_header.go
+++ b/internal/protogen/table_header.go
@@ -69,10 +69,10 @@ func (t *tableHeader) checkNameConflicts(name string, cursor int) error {
 		position1 := excel.Postion(t.NameRow-1, foundCursor)
 		position2 := excel.Postion(t.NameRow-1, cursor)
 		if t.transpose {
-			position1 = excel.Postion(foundCursor, int(t.NameRow-1))
-			position2 = excel.Postion(cursor, int(t.TypeRow-1))
+			position1 = excel.Postion(foundCursor, t.NameRow-1)
+			position2 = excel.Postion(cursor, t.NameRow-1)
 		}
-		return xerrors.E1000(name, position1, position2)
+		return xerrors.E0003(name, position1, position2)
 	}
 	return nil
 }
diff --git a/xerrors/ecode.go b/xerrors/ecode.go
index 684fbc1..58c3ad3 100644
--- a/xerrors/ecode.go
+++ b/xerrors/ecode.go
@@ -51,9 +51,9 @@ func E0002(filename, messageName, errstr string, lines string) error {
 	})
 }
 
-// E1000: column name conflicts in name row.
-func E1000(name, positon1, positon2 string) error {
-	return renderEcode("E1000", map[string]any{
+// E0003: duplicate column name.
+func E0003(name, positon1, positon2 string) error {
+	return renderEcode("E0003", map[string]any{
 		"Name":      name,
 		"Position1": positon1,
 		"Position2": positon2,


Test patch:
diff --git a/internal/confgen/table_parser_test.go b/internal/confgen/table_parser_test.go
index 49a3928..2f558ad 100644
--- a/internal/confgen/table_parser_test.go
+++ b/internal/confgen/table_parser_test.go
@@ -122,6 +122,27 @@ func TestTableParser_parseVerticalMapWithDuplicateKey(t *testing.T) {
 			wantErr: true,
 			errcode: "E2005",
 		},
+		{
+			name:   "duplicate col name",
+			parser: testParser,
+			args: args{
+				sheet: &book.Sheet{
+					Name: "MallConf",
+					Table: &book.Table{
+						MaxRow: 4,
+						MaxCol: 3,
+						Rows: [][]string{
+							{"ShopID", "GoodsID", "GoodsID", "Price"},
+							{"1", "1", "1", "10"},
+							{"2", "2", "2", "20"},
+							{"3", "3", "3", "30"},
+						},
+					},
+				},
+			},
+			wantErr: true,
+			errcode: "E0003",
+		},
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
diff --git a/load/load_test.go b/load/load_test.go
index f3006ce..7471ff7 100644
--- a/load/load_test.go
+++ b/load/load_test.go
@@ -413,3 +413,13 @@ func TestLoadEmptyText(t *testing.T) {
 	)
 	require.NoError(t, err, "should return no error")
 }
+
+func TestLoadCSV_E0003(t *testing.T) {
+	err := Load(&unittestpb.ItemConf{}, "../testdata/", format.CSV,
+		SubdirRewrites(map[string]string{"unittest/Unittest": "unittest/Unittest2"}),
+	)
+	require.Error(t, err, "should return an error")
+	desc := xerrors.NewDesc(err)
+	require.Equal(t, "E0003", desc.ErrCode())
+	t.Logf("error: %s", desc.String())
+}
diff --git a/store/jsonutil_test.go b/store/jsonutil_test.go
index 92d227d..ad4ef58 100644
--- a/store/jsonutil_test.go
+++ b/store/jsonutil_test.go
@@ -22,6 +22,36 @@ func Test_processWhenEmitTimezones(t *testing.T) {
 		want    string
 		wantErr bool
 	}{
+		{
+			name: "UTC-empty",
+			args: args{
+				message: &unittestpb.PatchMergeConf{
+					Name: "test",
+					Time: &unittestpb.PatchMergeConf_Time{
+						Start: &timestamppb.Timestamp{},
+					},
+				},
+				locationName:  "UTC",
+				useProtoNames: true,
+			},
+			want:    `{"name":"test","time":{"start":"1970-01-01T00:00:00Z"}}`,
+			wantErr: false,
+		},
+		{
+			name: "UTC-nil",
+			args: args{
+				message: &unittestpb.PatchMergeConf{
+					Name: "test",
+					Time: &unittestpb.PatchMergeConf_Time{
+						Start: nil,
+					},
+				},
+				locationName:  "UTC",
+				useProtoNames: true,
+			},
+			want:    `{"name":"test","time":{}}`,
+			wantErr: false,
+		},
 		{
 			name: "UTC",
 			args: args{
diff --git a/testdata/unittest/Unittest2#ItemConf.csv b/testdata/unittest/Unittest2#ItemConf.csv
new file mode 100644
index 0000000..e41cd1a
--- /dev/null
+++ b/testdata/unittest/Unittest2#ItemConf.csv
@@ -0,0 +1,6 @@
+ID,Num,Num
+"map<uint32, .Item>|{sequence:1}",int32
+Item's ID,Item's num
+1,100,1
+2,200
+3,300