Add ID field to Entry struct with auto-generation on marshal/unmarshal

This commit is contained in:
Dan Jones 2026-03-07 18:57:33 -06:00
commit 01d6dd9b0b
2 changed files with 338 additions and 66 deletions

View file

@ -20,18 +20,47 @@ import (
const DateFormat = tools.DateFormat const DateFormat = tools.DateFormat
type Entry struct { type Entry struct {
// ID is an optional, but recommended field.
// When marshaling/unmarshaling, if ID is empty, one is generated.
ID string
Title string Title string
Date time.Time Date time.Time
Fields Metas Fields Metas
} }
func GenerateID() string {
host, hostErr := os.Hostname()
if hostErr != nil {
host = "localhost"
}
return fmt.Sprintf(
"tag:%s,%s:my-log/%s",
host,
time.Now().Local().Format("2006"),
uuid.NewString(),
)
}
func EnsureID(id *string, fields Metas) {
if *id != "" {
return
}
metaID, hasMetaID := fields.Get("id")
var isStringID bool
*id, isStringID = metaID.(string)
if !hasMetaID || !isStringID {
*id = GenerateID()
}
}
type metaRes struct { type metaRes struct {
out []byte out []byte
err error err error
} }
func (e Entry) getFieldMarshalChan() chan metaRes { func getFieldMarshalChan(fields Metas) chan metaRes {
size := len(e.Fields) size := len(fields)
ch := make(chan metaRes, size) ch := make(chan metaRes, size)
var wg sync.WaitGroup var wg sync.WaitGroup
@ -53,7 +82,7 @@ func (e Entry) getFieldMarshalChan() chan metaRes {
ch <- metaRes{o, er} ch <- metaRes{o, er}
} }
}(e.Fields[i]) }(fields[i])
} }
go func() { go func() {
@ -71,16 +100,10 @@ func (e Entry) MarshalText() ([]byte, error) {
if e.Date.IsZero() { if e.Date.IsZero() {
return []byte{}, ErrorMissingDate return []byte{}, ErrorMissingDate
} }
EnsureID(&e.ID, e.Fields)
fields := e.Fields.Set("id", e.ID)
if _, hasId := e.Fields.Get("id"); !hasId { ch := getFieldMarshalChan(fields)
host, hostErr := os.Hostname()
if hostErr != nil {
host = "localhost"
}
e.Fields = e.Fields.Set("id", fmt.Sprintf("tag:%s,%s:my-log/%s", host, time.Now().Format("2006"), uuid.NewString()))
}
ch := e.getFieldMarshalChan()
buff := &bytes.Buffer{} buff := &bytes.Buffer{}
buff.WriteString("@begin ") buff.WriteString("@begin ")
buff.WriteString(e.Date.Format(DateFormat)) buff.WriteString(e.Date.Format(DateFormat))
@ -124,8 +147,14 @@ func (m *Entry) UnmarshalText(in []byte) error {
m.Date = d m.Date = d
for meta := range ch { for meta := range ch {
if meta.Key == "id" {
if id, idIsString := meta.Value.(string); idIsString {
m.ID = id
}
}
m.Fields = append(m.Fields, meta) m.Fields = append(m.Fields, meta)
} }
EnsureID(&m.ID, m.Fields)
return nil return nil
} }
@ -195,11 +224,13 @@ func (e Entry) MarshalJSON() ([]byte, error) {
if e.Date == (time.Time{}) { if e.Date == (time.Time{}) {
return []byte{}, ErrorMissingDate return []byte{}, ErrorMissingDate
} }
EnsureID(&e.ID, e.Fields)
fields := e.Fields.Set("id", e.ID)
out := map[string]any{} out := map[string]any{}
out["title"] = e.Title out["title"] = e.Title
out["date"] = e.Date.Format(time.RFC3339) out["date"] = e.Date.Format(time.RFC3339)
maps.Copy(out, e.Fields.Map()) maps.Copy(out, fields.Map())
return json.Marshal(out) return json.Marshal(out)
} }
@ -281,5 +312,6 @@ func (e *Entry) UnmarshalJSON(in []byte) error {
} }
e.Fields = append(e.Fields, m) e.Fields = append(e.Fields, m)
} }
EnsureID(&e.ID, e.Fields)
return nil return nil
} }

View file

@ -9,7 +9,6 @@ import (
"testing" "testing"
"time" "time"
"codeberg.org/danjones000/my-log/internal/testutil/bep"
"github.com/nalgeon/be" "github.com/nalgeon/be"
) )
@ -126,7 +125,7 @@ func TestEntryMarshal(t *testing.T) {
func getEntryMarshalTestRunner(title string, date time.Time, fields []Meta, first string, lines []string, err error) func(*testing.T) { func getEntryMarshalTestRunner(title string, date time.Time, fields []Meta, first string, lines []string, err error) func(*testing.T) {
return func(t *testing.T) { return func(t *testing.T) {
en := Entry{title, date, fields} en := Entry{Title: title, Date: date, Fields: fields}
o, er := en.MarshalText() o, er := en.MarshalText()
be.Err(t, er, err) be.Err(t, er, err)
if first == "" { if first == "" {
@ -154,22 +153,24 @@ func TestEntryUnmarshal(t *testing.T) {
in string in string
title string title string
date time.Time date time.Time
id string
fields []Meta fields []Meta
err error err error
}{ }{
{"one-line", "@begin " + whens + " - A Title @end", "A Title", when, simple, nil}, {"one-line", "@begin " + whens + " - A Title @end", "A Title", when, "", simple, nil},
{"rfc3999-date", "@begin " + when.Format(time.RFC3339) + " - A Title @end", "A Title", when, simple, nil}, {"with-id", "@begin " + whens + " - A Title\n@id jimmy-john @end", "A Title", when, "jimmy-john", simple, nil},
{"multi-title", "@begin " + whens + " - A Title\nwith break @end", "A Title\nwith break", when, simple, nil}, {"rfc3999-date", "@begin " + when.Format(time.RFC3339) + " - A Title @end", "A Title", when, "", simple, nil},
{"no-title", "@begin " + whens + " - @end", "", when, simple, ErrorMissingTitle}, {"multi-title", "@begin " + whens + " - A Title\nwith break @end", "A Title\nwith break", when, "", simple, nil},
{"parse-error", "this is no good", "", when, simple, ErrorParsing}, {"no-title", "@begin " + whens + " - @end", "", when, "", simple, ErrorMissingTitle},
{"no-date", "@begin - A Title @end", "A Title", when, simple, ErrorMissingDate}, {"parse-error", "this is no good", "", when, "", simple, ErrorParsing},
{"bad-date", "@begin not-a-real date - A Title @end", "A Title", when, simple, ErrorParsing}, {"no-date", "@begin - A Title @end", "A Title", when, "", simple, ErrorMissingDate},
{"one-field", "@begin " + whens + " - A Title\n@age 41 @end", "A Title", when, []Meta{{"age", 41}}, nil}, {"bad-date", "@begin not-a-real date - A Title @end", "A Title", when, "", simple, ErrorParsing},
{"one-field", "@begin " + whens + " - A Title\n@age 41 @end", "A Title", when, "", []Meta{{"age", 41}}, nil},
{ {
"two-fields", "two-fields",
"@begin " + whens + " - A Title\n@age 41\n@cool true @end", "@begin " + whens + " - A Title\n@age 41\n@cool true @end",
"A Title", "A Title",
when, when, "",
[]Meta{{"age", 41}, {"cool", true}}, []Meta{{"age", 41}, {"cool", true}},
nil, nil,
}, },
@ -177,7 +178,7 @@ func TestEntryUnmarshal(t *testing.T) {
"obj-field", "obj-field",
"@begin " + whens + " - A Title\n" + `@me {"name":"Dan","coder":true} @end`, "@begin " + whens + " - A Title\n" + `@me {"name":"Dan","coder":true} @end`,
"A Title", "A Title",
when, when, "",
[]Meta{{"me", json.RawMessage(`{"name":"Dan","coder":true}`)}}, []Meta{{"me", json.RawMessage(`{"name":"Dan","coder":true}`)}},
nil, nil,
}, },
@ -185,7 +186,7 @@ func TestEntryUnmarshal(t *testing.T) {
"nested-field", "nested-field",
"@begin " + whens + " - A Title\n@me:name Dan\n@me:coder true @end", "@begin " + whens + " - A Title\n@me:name Dan\n@me:coder true @end",
"A Title", "A Title",
when, when, "",
[]Meta{{"me:name", "Dan"}, {"me:coder", true}}, []Meta{{"me:name", "Dan"}, {"me:coder", true}},
nil, nil,
}, },
@ -193,7 +194,7 @@ func TestEntryUnmarshal(t *testing.T) {
"nested-field-dot", "nested-field-dot",
"@begin " + whens + " - A Title\n@me:name Dan Jones\n@me:name:nick Danny\n@me:coder true @end", "@begin " + whens + " - A Title\n@me:name Dan Jones\n@me:name:nick Danny\n@me:coder true @end",
"A Title", "A Title",
when, when, "",
[]Meta{{"me:name", "Dan Jones"}, {"me:name:nick", "Danny"}, {"me:coder", true}}, []Meta{{"me:name", "Dan Jones"}, {"me:name:nick", "Danny"}, {"me:coder", true}},
nil, nil,
}, },
@ -201,18 +202,18 @@ func TestEntryUnmarshal(t *testing.T) {
"json-field", "json-field",
"@begin " + whens + " - Some Guy\n" + `@json {"name":"Dan","coder":true} @end`, "@begin " + whens + " - Some Guy\n" + `@json {"name":"Dan","coder":true} @end`,
"Some Guy", "Some Guy",
when, when, "",
[]Meta{{"name", "Dan"}, {"coder", true}}, []Meta{{"name", "Dan"}, {"coder", true}},
nil, nil,
}, },
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, getEntryUnmarshalTestRunner(tt.in, tt.title, tt.date, tt.fields, tt.err)) t.Run(tt.name, getEntryUnmarshalTestRunner(tt.in, tt.title, tt.date, tt.id, tt.fields, tt.err))
} }
} }
func getEntryUnmarshalTestRunner(in string, title string, date time.Time, fields []Meta, err error) func(*testing.T) { func getEntryUnmarshalTestRunner(in string, title string, date time.Time, id string, fields []Meta, err error) func(*testing.T) {
return func(t *testing.T) { return func(t *testing.T) {
e := &Entry{} e := &Entry{}
er := e.UnmarshalText([]byte(in)) er := e.UnmarshalText([]byte(in))
@ -223,6 +224,9 @@ func getEntryUnmarshalTestRunner(in string, title string, date time.Time, fields
be.Equal(t, e.Title, title) be.Equal(t, e.Title, title)
be.True(t, e.Date.After(date.Add(-time.Second)) && e.Date.Before(date.Add(time.Second))) be.True(t, e.Date.After(date.Add(-time.Second)) && e.Date.Before(date.Add(time.Second)))
if id != "" {
be.Equal(t, e.ID, id)
}
for _, f := range fields { for _, f := range fields {
got := false got := false
for _, m := range e.Fields { for _, m := range e.Fields {
@ -263,46 +267,282 @@ func TestEntryJsonMarshal(t *testing.T) {
whens := when.Format(time.RFC3339) whens := when.Format(time.RFC3339)
simple := []Meta{} simple := []Meta{}
tests := []struct { tests := []struct {
name string name string
title string title string
date time.Time date time.Time
fields []Meta fields []Meta
out string check func(*testing.T, []byte)
err error wantErr error
}{ }{
{"simple", "A Title", when, simple, `{"title":"A Title","date":"` + whens + `"}`, nil}, {
{"one-field", "A Title 2", when, []Meta{{"age", 41}}, `{"title":"A Title 2","date":"` + whens + `","age":41}`, nil}, "simple",
{"skip-title-field", "A Title", when, []Meta{{"title", "Different title"}}, `{"title":"A Title","date":"` + whens + `"}`, nil}, "A Title",
{"skip-date-field", "A Title", when, []Meta{{"date", when.Add(time.Hour)}}, `{"title":"A Title","date":"` + whens + `"}`, nil}, when,
{"skip-dupe-field", "A Title", when, []Meta{{"foo", "bar"}, {"foo", "baz"}}, `{"title":"A Title","date":"` + whens + `","foo": "bar"}`, nil}, simple,
{"two-fields", "A Title", when, []Meta{{"foo", "bar"}, {"baz", 42}}, `{"title":"A Title","date":"` + whens + `","foo": "bar","baz":42}`, nil}, func(t *testing.T, o []byte) {
{"empty-title", "", when, simple, "", ErrorMissingTitle}, var m map[string]any
{"empty-date", "A Title", time.Time{}, simple, "", ErrorMissingDate}, err := json.Unmarshal(o, &m)
{"obj-field", "A Title", when, []Meta{{"obj", json.RawMessage(`{"foo":"bar","title":"Sub-title"}`)}}, `{"title":"A Title","date":"` + whens + `","obj":{"foo":"bar","title":"Sub-title"}}`, nil}, be.Err(t, err, nil)
{"date-field", "A Title", when, []Meta{{"when", when.Add(time.Hour)}}, `{"title":"A Title","date":"` + whens + `","when":"` + when.Add(time.Hour).Format(time.RFC3339) + `"}`, nil}, be.Equal(t, m["title"].(string), "A Title")
{"json-field", "A Title", when, []Meta{{"json", json.RawMessage(`{"age": 41, "cool": true, "name": "Jim"}`)}}, `{"title":"A Title","date":"` + whens + `","age":41,"cool": true, "name": "Jim"}`, nil}, be.Equal(t, m["date"].(string), whens)
{"nested-field", "A Title", when, []Meta{{"obj:foo", "bar"}, {"obj:title", "Sub-title"}}, `{"title":"A Title","date":"` + whens + `","obj":{"foo":"bar","title":"Sub-title"}}`, nil}, _, hasID := m["id"].(string)
{"double-nested-field", "A Title", when, []Meta{{"obj:foo", "bar"}, {"obj:me:name", "Dan"}, {"obj:me:age", 27}}, `{"title":"A Title","date":"` + whens + `","obj":{"foo":"bar","me":{"name":"Dan","age":27}}}`, nil}, be.True(t, hasID)
{"nested-plus-json", "A Title", when, []Meta{{"obj:foo", "bar"}, {"obj:me", json.RawMessage(`{"name":"Dan","age":27}`)}}, `{"title":"A Title","date":"` + whens + `","obj":{"foo":"bar","me":{"name":"Dan","age":27}}}`, nil}, },
{"nested-part", "A Title", when, []Meta{{"obj:foo", "bar"}, {"obj:me", "Dan"}, {"obj:me:age", 27}}, `{"title":"A Title","date":"` + whens + `","obj":{"foo":"bar","me":{".":"Dan","age":27}}}`, nil}, nil,
{"nested-part-order", "A Title", when, []Meta{{"obj:foo", "bar"}, {"obj:me:age", 27}, {"obj:me", "Dan"}}, `{"title":"A Title","date":"` + whens + `","obj":{"foo":"bar","me":{".":"Dan","age":27}}}`, nil}, },
{"nested-part-order-two", "A Title", when, []Meta{{"obj:foo", "bar"}, {"obj:me:age", 27}, {"obj:me", "Dan"}, {"obj:me:cool", true}}, `{"title":"A Title","date":"` + whens + `","obj":{"foo":"bar","me":{".":"Dan","age":27,"cool":true}}}`, nil}, {
"one-field",
"A Title 2",
when,
[]Meta{{"age", 41}},
func(t *testing.T, o []byte) {
var m map[string]any
err := json.Unmarshal(o, &m)
be.Err(t, err, nil)
be.Equal(t, m["title"].(string), "A Title 2")
be.Equal(t, m["date"].(string), whens)
be.Equal(t, m["age"].(float64), float64(41))
},
nil,
},
{
"skip-title-field",
"A Title",
when,
[]Meta{{"title", "Different title"}},
func(t *testing.T, o []byte) {
var m map[string]any
err := json.Unmarshal(o, &m)
be.Err(t, err, nil)
be.Equal(t, m["title"].(string), "A Title")
},
nil,
},
{
"skip-date-field",
"A Title",
when,
[]Meta{{"date", when.Add(time.Hour)}},
func(t *testing.T, o []byte) {
var m map[string]any
err := json.Unmarshal(o, &m)
be.Err(t, err, nil)
be.Equal(t, m["date"].(string), whens)
},
nil,
},
{
"skip-dupe-field",
"A Title",
when,
[]Meta{{"foo", "bar"}, {"foo", "baz"}},
func(t *testing.T, o []byte) {
var m map[string]any
err := json.Unmarshal(o, &m)
be.Err(t, err, nil)
be.Equal(t, m["foo"].(string), "bar")
},
nil,
},
{
"two-fields",
"A Title",
when,
[]Meta{{"foo", "bar"}, {"baz", 42}},
func(t *testing.T, o []byte) {
var m map[string]any
err := json.Unmarshal(o, &m)
be.Err(t, err, nil)
be.Equal(t, m["foo"].(string), "bar")
be.Equal(t, m["baz"].(float64), float64(42))
},
nil,
},
{
"empty-title",
"",
when,
simple,
nil,
ErrorMissingTitle,
},
{
"empty-date",
"A Title",
time.Time{},
simple,
nil,
ErrorMissingDate,
},
{
"obj-field",
"A Title",
when,
[]Meta{{"obj", json.RawMessage(`{"foo":"bar","title":"Sub-title"}`)}},
func(t *testing.T, o []byte) {
var m map[string]any
err := json.Unmarshal(o, &m)
be.Err(t, err, nil)
obj, ok := m["obj"].(map[string]any)
be.True(t, ok)
be.Equal(t, obj["foo"].(string), "bar")
be.Equal(t, obj["title"].(string), "Sub-title")
},
nil,
},
{
"date-field",
"A Title",
when,
[]Meta{{"when", when.Add(time.Hour)}},
func(t *testing.T, o []byte) {
var m map[string]any
err := json.Unmarshal(o, &m)
be.Err(t, err, nil)
be.Equal(t, m["when"].(string), when.Add(time.Hour).Format(time.RFC3339))
},
nil,
},
{
"json-field",
"A Title",
when,
[]Meta{{"json", json.RawMessage(`{"age": 41, "cool": true, "name": "Jim"}`)}},
func(t *testing.T, o []byte) {
var m map[string]any
err := json.Unmarshal(o, &m)
be.Err(t, err, nil)
be.Equal(t, m["age"].(float64), float64(41))
be.Equal(t, m["cool"].(bool), true)
be.Equal(t, m["name"].(string), "Jim")
},
nil,
},
{
"nested-field",
"A Title",
when,
[]Meta{{"obj:foo", "bar"}, {"obj:title", "Sub-title"}},
func(t *testing.T, o []byte) {
var m map[string]any
err := json.Unmarshal(o, &m)
be.Err(t, err, nil)
obj, ok := m["obj"].(map[string]any)
be.True(t, ok)
be.Equal(t, obj["foo"].(string), "bar")
be.Equal(t, obj["title"].(string), "Sub-title")
},
nil,
},
{
"double-nested-field",
"A Title",
when,
[]Meta{{"obj:foo", "bar"}, {"obj:me:name", "Dan"}, {"obj:me:age", 27}},
func(t *testing.T, o []byte) {
var m map[string]any
err := json.Unmarshal(o, &m)
be.Err(t, err, nil)
obj, ok := m["obj"].(map[string]any)
be.True(t, ok)
be.Equal(t, obj["foo"].(string), "bar")
me, ok := obj["me"].(map[string]any)
be.True(t, ok)
be.Equal(t, me["name"].(string), "Dan")
be.Equal(t, me["age"].(float64), float64(27))
},
nil,
},
{
"nested-plus-json",
"A Title",
when,
[]Meta{{"obj:foo", "bar"}, {"obj:me", json.RawMessage(`{"name":"Dan","age":27}`)}},
func(t *testing.T, o []byte) {
var m map[string]any
err := json.Unmarshal(o, &m)
be.Err(t, err, nil)
obj, ok := m["obj"].(map[string]any)
be.True(t, ok)
be.Equal(t, obj["foo"].(string), "bar")
me, ok := obj["me"].(map[string]any)
be.True(t, ok)
be.Equal(t, me["name"].(string), "Dan")
be.Equal(t, me["age"].(float64), float64(27))
},
nil,
},
{
"nested-part",
"A Title",
when,
[]Meta{{"obj:foo", "bar"}, {"obj:me", "Dan"}, {"obj:me:age", 27}},
func(t *testing.T, o []byte) {
var m map[string]any
err := json.Unmarshal(o, &m)
be.Err(t, err, nil)
obj, ok := m["obj"].(map[string]any)
be.True(t, ok)
be.Equal(t, obj["foo"].(string), "bar")
me, ok := obj["me"].(map[string]any)
be.True(t, ok)
be.Equal(t, me["."].(string), "Dan")
be.Equal(t, me["age"].(float64), float64(27))
},
nil,
},
{
"nested-part-order",
"A Title",
when,
[]Meta{{"obj:foo", "bar"}, {"obj:me:age", 27}, {"obj:me", "Dan"}},
func(t *testing.T, o []byte) {
var m map[string]any
err := json.Unmarshal(o, &m)
be.Err(t, err, nil)
obj, ok := m["obj"].(map[string]any)
be.True(t, ok)
be.Equal(t, obj["foo"].(string), "bar")
me, ok := obj["me"].(map[string]any)
be.True(t, ok)
be.Equal(t, me["."].(string), "Dan")
be.Equal(t, me["age"].(float64), float64(27))
},
nil,
},
{
"nested-part-order-two",
"A Title",
when,
[]Meta{{"obj:foo", "bar"}, {"obj:me:age", 27}, {"obj:me", "Dan"}, {"obj:me:cool", true}},
func(t *testing.T, o []byte) {
var m map[string]any
err := json.Unmarshal(o, &m)
be.Err(t, err, nil)
obj, ok := m["obj"].(map[string]any)
be.True(t, ok)
be.Equal(t, obj["foo"].(string), "bar")
me, ok := obj["me"].(map[string]any)
be.True(t, ok)
be.Equal(t, me["."].(string), "Dan")
be.Equal(t, me["age"].(float64), float64(27))
be.Equal(t, me["cool"].(bool), true)
},
nil,
},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, getEntryJsonMarshalTestRunner(tt.title, tt.date, tt.fields, tt.out, tt.err)) t.Run(tt.name, func(t *testing.T) {
} e := Entry{Title: tt.title, Date: tt.date, Fields: tt.fields}
} o, er := json.Marshal(e)
if tt.wantErr == nil {
func getEntryJsonMarshalTestRunner(title string, date time.Time, fields []Meta, out string, err error) func(t *testing.T) { be.Err(t, er, nil)
return func(t *testing.T) { tt.check(t, o)
e := Entry{title, date, fields} } else {
o, er := json.Marshal(e) be.Err(t, er, tt.wantErr)
if err == nil { }
bep.JSON(t, o, []byte(out)) })
} else {
be.Err(t, er, err)
}
} }
} }