Skip to content

Commit 88ce6ed

Browse files
authored
Merge pull request #24 from pepabo/feature/update-output-utils
Add comprehensive CSV output support
2 parents 1f5fb75 + cb8509a commit 88ce6ed

File tree

6 files changed

+176
-7
lines changed

6 files changed

+176
-7
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,10 +96,12 @@ All list commands support multiple output formats:
9696

9797
- `yaml` (default)
9898
- `json`
99+
- `csv`
99100

100101
Example:
101102
```bash
102103
onecli user list --output json
104+
onecli user list --output csv
103105
```
104106

105107
## Configuration

cmd/app.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,9 +95,9 @@ func init() {
9595
appCmd.AddCommand(appListCmd)
9696
appCmd.AddCommand(appListUsersCmd)
9797

98-
appListCmd.Flags().StringVarP(&appOutput, "output", "o", "yaml", "Output format (yaml, json)")
98+
appListCmd.Flags().StringVarP(&appOutput, "output", "o", "yaml", "Output format (yaml, json, csv)")
9999
appListCmd.Flags().StringVar(&appQueryName, "name", "", "Filter apps by name")
100100
appListCmd.Flags().BoolVar(&appDetail, "detail", false, "Include user details for each app")
101101

102-
appListUsersCmd.Flags().StringVarP(&appOutput, "output", "o", "yaml", "Output format (yaml, json)")
102+
appListUsersCmd.Flags().StringVarP(&appOutput, "output", "o", "yaml", "Output format (yaml, json, csv)")
103103
}

cmd/event.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ func init() {
159159
eventCmd.AddCommand(eventListCmd)
160160
eventCmd.AddCommand(eventTypesCmd)
161161

162-
eventListCmd.Flags().StringVarP(&eventOutput, "output", "o", "yaml", "Output format (yaml, json)")
162+
eventListCmd.Flags().StringVarP(&eventOutput, "output", "o", "yaml", "Output format (yaml, json, csv)")
163163
eventListCmd.Flags().StringVar(&eventQueryClientID, "client-id", "", "Filter events by client ID")
164164
eventListCmd.Flags().StringVar(&eventQueryCreatedAt, "created-at", "", "Filter events by created at")
165165
eventListCmd.Flags().StringVar(&eventQueryDirectoryID, "directory-id", "", "Filter events by directory ID")
@@ -174,5 +174,5 @@ func init() {
174174
// Make --type and --type-id mutually exclusive
175175
eventListCmd.MarkFlagsMutuallyExclusive("type", "type-id")
176176

177-
eventTypesCmd.Flags().StringVarP(&eventOutput, "output", "o", "yaml", "Output format (yaml, json)")
177+
eventTypesCmd.Flags().StringVarP(&eventOutput, "output", "o", "yaml", "Output format (yaml, json, csv)")
178178
}

cmd/user.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ func init() {
178178
modifyCmd.AddCommand(modifyEmailCmd)
179179
userCmd.AddCommand(addCmd)
180180

181-
listCmd.Flags().StringVarP(&output, "output", "o", "yaml", "Output format (yaml, json)")
181+
listCmd.Flags().StringVarP(&output, "output", "o", "yaml", "Output format (yaml, json, csv)")
182182
listCmd.Flags().StringVar(&userQueryEmail, "email", "", "Filter users by email")
183183
listCmd.Flags().StringVar(&userQueryUsername, "username", "", "Filter users by username")
184184
listCmd.Flags().StringVar(&userQueryFirstname, "firstname", "", "Filter users by first name")

utils/output.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
package utils
22

33
import (
4+
"encoding/csv"
45
"encoding/json"
6+
"fmt"
57
"io"
68
"os"
9+
"reflect"
10+
"strconv"
11+
"time"
712

813
"github.com/goccy/go-yaml"
914
)
@@ -14,6 +19,7 @@ type OutputFormat string
1419
const (
1520
OutputFormatYAML OutputFormat = "yaml"
1621
OutputFormatJSON OutputFormat = "json"
22+
OutputFormatCSV OutputFormat = "csv"
1723
)
1824

1925
// PrintOutput は指定された形式でデータを出力します
@@ -31,7 +37,98 @@ func PrintOutput(data any, format OutputFormat, writer io.Writer) error {
3137
return encoder.Encode(data)
3238
case OutputFormatYAML:
3339
return yaml.NewEncoder(writer).Encode(data)
40+
case OutputFormatCSV:
41+
return encodeCSV(data, writer)
3442
default:
3543
return yaml.NewEncoder(writer).Encode(data)
3644
}
3745
}
46+
47+
// encodeCSV はデータをCSV形式でエンコードします
48+
func encodeCSV(data any, writer io.Writer) error {
49+
csvWriter := csv.NewWriter(writer)
50+
defer csvWriter.Flush()
51+
52+
// データがスライスでない場合はエラー
53+
val := reflect.ValueOf(data)
54+
if val.Kind() != reflect.Slice {
55+
return fmt.Errorf("CSV output requires slice data, got %v", val.Kind())
56+
}
57+
58+
if val.Len() == 0 {
59+
return nil
60+
}
61+
62+
// 最初の要素からヘッダーを生成
63+
firstElem := val.Index(0)
64+
if firstElem.Kind() == reflect.Ptr {
65+
firstElem = firstElem.Elem()
66+
}
67+
68+
if firstElem.Kind() != reflect.Struct {
69+
return fmt.Errorf("CSV output requires struct slice, got %v", firstElem.Kind())
70+
}
71+
72+
// ヘッダーを生成
73+
var headers []string
74+
elemType := firstElem.Type()
75+
for i := 0; i < elemType.NumField(); i++ {
76+
field := elemType.Field(i)
77+
headers = append(headers, field.Name)
78+
}
79+
80+
// ヘッダーを書き込み
81+
if err := csvWriter.Write(headers); err != nil {
82+
return fmt.Errorf("error writing CSV headers: %v", err)
83+
}
84+
85+
// データ行を書き込み
86+
for i := 0; i < val.Len(); i++ {
87+
elem := val.Index(i)
88+
if elem.Kind() == reflect.Ptr {
89+
elem = elem.Elem()
90+
}
91+
92+
var row []string
93+
for j := 0; j < elem.NumField(); j++ {
94+
field := elem.Field(j)
95+
row = append(row, formatFieldValue(field))
96+
}
97+
98+
if err := csvWriter.Write(row); err != nil {
99+
return fmt.Errorf("error writing CSV row %d: %v", i, err)
100+
}
101+
}
102+
103+
return nil
104+
}
105+
106+
// formatFieldValue はフィールドの値を文字列に変換します
107+
func formatFieldValue(field reflect.Value) string {
108+
switch field.Kind() {
109+
case reflect.String:
110+
return field.String()
111+
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
112+
return strconv.FormatInt(field.Int(), 10)
113+
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
114+
return strconv.FormatUint(field.Uint(), 10)
115+
case reflect.Float32, reflect.Float64:
116+
return strconv.FormatFloat(field.Float(), 'f', -1, 64)
117+
case reflect.Bool:
118+
return strconv.FormatBool(field.Bool())
119+
case reflect.Struct:
120+
// time.Timeの場合はRFC3339形式で出力
121+
if field.Type() == reflect.TypeOf(time.Time{}) {
122+
t := field.Interface().(time.Time)
123+
return t.Format(time.RFC3339)
124+
}
125+
return fmt.Sprintf("%v", field.Interface())
126+
case reflect.Ptr:
127+
if field.IsNil() {
128+
return ""
129+
}
130+
return formatFieldValue(field.Elem())
131+
default:
132+
return fmt.Sprintf("%v", field.Interface())
133+
}
134+
}

utils/output_test.go

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,73 @@ func TestPrintOutput(t *testing.T) {
106106
expected: "",
107107
wantErr: true,
108108
},
109+
{
110+
name: "正常系: CSV形式で出力",
111+
format: OutputFormatCSV,
112+
data: []models.User{
113+
{
114+
ID: 1,
115+
Username: "testuser1",
116+
117+
Firstname: "Test",
118+
Lastname: "User1",
119+
CreatedAt: time.Date(2024, 4, 1, 12, 0, 0, 0, time.UTC),
120+
UpdatedAt: time.Date(2024, 4, 1, 12, 0, 0, 0, time.UTC),
121+
ActivatedAt: time.Date(2024, 4, 1, 12, 0, 0, 0, time.UTC),
122+
State: 1,
123+
Status: 1,
124+
},
125+
{
126+
ID: 2,
127+
Username: "testuser2",
128+
129+
Firstname: "Test",
130+
Lastname: "User2",
131+
CreatedAt: time.Date(2024, 4, 2, 12, 0, 0, 0, time.UTC),
132+
UpdatedAt: time.Date(2024, 4, 2, 12, 0, 0, 0, time.UTC),
133+
ActivatedAt: time.Date(2024, 4, 2, 12, 0, 0, 0, time.UTC),
134+
State: 1,
135+
Status: 1,
136+
},
137+
},
138+
expected: "Firstname,Lastname,Username,Email,DistinguishedName,Samaccountname,UserPrincipalName,MemberOf,Phone,Password,PasswordConfirmation,PasswordAlgorithm,Salt,Title,Company,Department,ManagerADID,Comment,CreatedAt,UpdatedAt,ActivatedAt,LastLogin,PasswordChangedAt,LockedUntil,InvitationSentAt,State,Status,InvalidLoginAttempts,GroupID,RoleIDs,DirectoryID,TrustedIDPID,ManagerUserID,ExternalID,ID,CustomAttributes\nTest,User1,testuser1,[email protected],,,,[],,,,,,,,,0,,2024-04-01T12:00:00Z,2024-04-01T12:00:00Z,2024-04-01T12:00:00Z,0001-01-01T00:00:00Z,0001-01-01T00:00:00Z,0001-01-01T00:00:00Z,0001-01-01T00:00:00Z,1,1,0,0,[],0,0,0,,1,map[]\nTest,User2,testuser2,[email protected],,,,[],,,,,,,,,0,,2024-04-02T12:00:00Z,2024-04-02T12:00:00Z,2024-04-02T12:00:00Z,0001-01-01T00:00:00Z,0001-01-01T00:00:00Z,0001-01-01T00:00:00Z,0001-01-01T00:00:00Z,1,1,0,0,[],0,0,0,,2,map[]\n",
139+
wantErr: false,
140+
},
141+
{
142+
name: "異常系: CSV形式でスライス以外のデータ",
143+
format: OutputFormatCSV,
144+
data: "not a slice",
145+
expected: "",
146+
wantErr: true,
147+
},
148+
{
149+
name: "異常系: CSV形式で空のスライス",
150+
format: OutputFormatCSV,
151+
data: []models.User{},
152+
expected: "",
153+
wantErr: false,
154+
},
155+
{
156+
name: "正常系: CSV形式で改行を含む文字列",
157+
format: OutputFormatCSV,
158+
data: []models.User{
159+
{
160+
ID: 1,
161+
Username: "testuser1",
162+
163+
Firstname: "Test",
164+
Lastname: "User1",
165+
Comment: "This is a comment\nwith multiple lines\nand special chars, like comma",
166+
CreatedAt: time.Date(2024, 4, 1, 12, 0, 0, 0, time.UTC),
167+
UpdatedAt: time.Date(2024, 4, 1, 12, 0, 0, 0, time.UTC),
168+
ActivatedAt: time.Date(2024, 4, 1, 12, 0, 0, 0, time.UTC),
169+
State: 1,
170+
Status: 1,
171+
},
172+
},
173+
expected: "Firstname,Lastname,Username,Email,DistinguishedName,Samaccountname,UserPrincipalName,MemberOf,Phone,Password,PasswordConfirmation,PasswordAlgorithm,Salt,Title,Company,Department,ManagerADID,Comment,CreatedAt,UpdatedAt,ActivatedAt,LastLogin,PasswordChangedAt,LockedUntil,InvitationSentAt,State,Status,InvalidLoginAttempts,GroupID,RoleIDs,DirectoryID,TrustedIDPID,ManagerUserID,ExternalID,ID,CustomAttributes\nTest,User1,testuser1,[email protected],,,,[],,,,,,,,,0,\"This is a comment\nwith multiple lines\nand special chars, like comma\",2024-04-01T12:00:00Z,2024-04-01T12:00:00Z,2024-04-01T12:00:00Z,0001-01-01T00:00:00Z,0001-01-01T00:00:00Z,0001-01-01T00:00:00Z,0001-01-01T00:00:00Z,1,1,0,0,[],0,0,0,,1,map[]\n",
174+
wantErr: false,
175+
},
109176
}
110177

111178
for _, tt := range tests {
@@ -120,9 +187,12 @@ func TestPrintOutput(t *testing.T) {
120187
assert.NoError(t, err)
121188
output := buf.String()
122189

123-
if tt.format == OutputFormatJSON {
190+
switch tt.format {
191+
case OutputFormatJSON:
124192
assert.JSONEq(t, tt.expected.(string), output)
125-
} else {
193+
case OutputFormatCSV:
194+
assert.Equal(t, tt.expected.(string), output)
195+
default:
126196
expectedBytes, err := yaml.Marshal(tt.expected)
127197
assert.NoError(t, err)
128198
assert.YAMLEq(t, string(expectedBytes), output)

0 commit comments

Comments
 (0)