From 494bd283f085530b098f0ada929be52d33d6acf0 Mon Sep 17 00:00:00 2001
From: Michael Kenney
Date: Wed, 20 May 2026 19:14:40 -0600
Subject: [PATCH 1/4] v1.0.0: Initial implementation
---
.gitignore | 11 +-
errors.go | 11 +-
examples_test.go | 8 +-
go.mod | 2 +-
interface.marshaler.go | 22 ++-
model.go | 322 +++++++++++++++++++++++++++--------------
model.importer.go | 83 +++++++----
model.iterator.go | 83 +++++------
model.marshaler.go | 37 +++--
model.sorter.go | 180 ++++++++++++++++++++---
model.types.go | 9 ++
model_test.go | 10 +-
value.go | 66 +++------
13 files changed, 557 insertions(+), 287 deletions(-)
create mode 100644 model.types.go
diff --git a/.gitignore b/.gitignore
index 7a8149a..3d8971c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,12 @@
/vendor
_*
-go.sum
\ No newline at end of file
+go.sum
+go.sum
+go.work
+vendor/
+_*
+benchmark*
+.claude*
+claude*
+CLAUDE*
+coverage*
diff --git a/errors.go b/errors.go
index 441e889..d56dfdf 100644
--- a/errors.go
+++ b/errors.go
@@ -5,9 +5,7 @@ import (
stdErrors "github.com/bdlm/std/v2/errors"
)
-/*
-Internal errors
-*/
+// Internal errors
var (
// InvalidIndex - The specified index does not exist.
InvalidIndex stdErrors.Error
@@ -26,6 +24,11 @@ var (
// InvalidDataSet - An attempt was made to store a data set that is
// with the model type
InvalidDataSet stdErrors.Error
+
+ // InvalidSortFlagCombination - The specified sort flag combination is
+ // invalid for this model type or in conflict with another flag.
+ // E.g. SortByKey and SortByValue.
+ InvalidSortFlagCombination stdErrors.Error
)
func init() {
@@ -33,4 +36,6 @@ func init() {
InvalidIndexType = errors.New("an invalid index datatype was used")
InvalidMethodContext = errors.New("a method was used in an invalid context")
ReadOnlyProperty = errors.New("cannot update a read-only property")
+ InvalidDataSet = errors.New("invalid data set for model type")
+ InvalidSortFlagCombination = errors.New("invalid sort flag combination")
}
diff --git a/examples_test.go b/examples_test.go
index 051d63e..907f036 100644
--- a/examples_test.go
+++ b/examples_test.go
@@ -12,19 +12,19 @@ import (
)
func ExampleNew() {
- mdl := model.New(stdModel.ModelTypeHash)
+ mdl := model.New(stdModel.ModelTypeHash, nil)
json.Unmarshal(
[]byte(`{"key1":"value1","key2":2,"key3":["one","two","three"],"key4":{"k1":"v1","k2":"v2"}}`),
&mdl,
)
- var key, val interface{}
+ var key, val any
for mdl.Next(&key, &val) {
if "key3" == key.(string) || "key4" == key.(string) {
- var k2, v2 interface{}
+ var k2, v2 any
var m2 stdModel.Model
m2, _ = val.(stdModel.Value).Model()
if nil == m2 {
- data, hash, index := mdl.Data()
+ data, hash, index := mdl.GetData()
log.Debugf("\n\n\ndata: %v\nhash: %v\nindex: %v\n\n\n", data, hash, index)
os.Exit(1)
}
diff --git a/go.mod b/go.mod
index 9e25ec3..68f18c8 100644
--- a/go.mod
+++ b/go.mod
@@ -3,7 +3,7 @@ module github.com/bdlm/model
go 1.26.2
require (
- github.com/bdlm/cast/v2 v2.1.0
+ github.com/bdlm/cast/v2 v2.1.4
github.com/bdlm/errors/v2 v2.1.2
github.com/bdlm/log/v2 v2.0.7
github.com/bdlm/std/v2 v2.1.0
diff --git a/interface.marshaler.go b/interface.marshaler.go
index 0740a93..1b857a5 100644
--- a/interface.marshaler.go
+++ b/interface.marshaler.go
@@ -1,22 +1,18 @@
package model
-/*
-Marshaler is the interface implemented by types that can serialize
-themselves into a static byte array.
-*/
+// Marshaler is the interface implemented by types that can serialize
+// themselves into a static byte array.
type Marshaler interface {
MarshalModel() ([]byte, error)
}
-/*
-Unmarshaler is the interface implemented by Models that can unmarshal a
-serialized description of themselves. The input can be assumed to be a valid
-encoding of a Model value. UnmarshalModel must copy the data if it wishes to
-retain the data after returning.
-
-By convention, to approximate the behavior of similar functionality in other
-packges, Unmarshalers implement UnmarshalModel([]byte("null")) as a no-op.
-*/
+// Unmarshaler is the interface implemented by Models that can unmarshal a
+// serialized description of themselves. The input can be assumed to be a valid
+// encoding of a Model value. UnmarshalModel must copy the data if it wishes to
+// retain the data after returning.
+//
+// By convention, to approximate the behavior of similar functionality in other
+// packages, Unmarshalers implement UnmarshalModel([]byte("null")) as a no-op.
type Unmarshaler interface {
UnmarshalModel(bytes []byte) error
}
diff --git a/model.go b/model.go
index d52a217..b671d80 100644
--- a/model.go
+++ b/model.go
@@ -8,9 +8,7 @@ import (
stdModel "github.com/bdlm/std/v2/model"
)
-/*
-modelType is a data type for defining Model types.
-*/
+// modelType is a data type for defining Model types.
type modelType int
const (
@@ -20,9 +18,7 @@ const (
List
)
-/*
-Model defines the model data structure.
-*/
+// Model defines the model data structure.
type Model struct {
id any // model identifier
locked bool // model read-only flag
@@ -35,62 +31,75 @@ type Model struct {
pos int // current stdModel.Iterator cursor position
}
-/*
-New returns a new stdModel.Model.
-*/
-func New(modelType stdModel.ModelType) *Model {
- return &Model{
+// New returns a new stdModel.Model.
+func New(modelType stdModel.ModelType, data any) *Model {
+ model := &Model{
mux: &sync.Mutex{},
typ: modelType,
hashIdx: map[string]int{},
idxHash: map[int]string{},
pos: -1,
}
+ if data != nil {
+ if _, err := model.importData(data); err != nil {
+ panic(err)
+ }
+ }
+ return model
}
-/*
-Data returns the current data set and indexes.
-*/
-func (mdl *Model) Data() ([]any, map[string]int, map[int]string) {
- return mdl.data, mdl.hashIdx, mdl.idxHash
-}
-
-/*
-Delete removes a value from this model.
-*/
+// Delete removes a value from this model.
func (mdl *Model) Delete(key any) error {
+ if mdl.locked {
+ return errors.WrapE(ReadOnlyProperty, errors.Errorf("model is locked"))
+ }
mdl.mux.Lock()
defer mdl.mux.Unlock()
if stdModel.ModelTypeList == mdl.GetType() {
- k := key.(int)
- if k > len(mdl.data) {
+ k := cast.To[int](key)
+ if k < 0 || k >= len(mdl.data) {
return errors.WrapE(InvalidIndex, errors.Errorf("index '%d' out of range", k))
}
- mdl.data = append(mdl.data[:key.(int)-1], mdl.data[key.(int):]...)
+ mdl.data = append(mdl.data[:k], mdl.data[k+1:]...)
return nil
}
- k := key.(string)
+ k := cast.To[string](key)
if idx, ok := mdl.hashIdx[k]; ok {
- mdl.data = append(mdl.data[:idx-1], mdl.data[idx:]...)
+ mdl.data = append(mdl.data[:idx], mdl.data[idx+1:]...)
delete(mdl.hashIdx, k)
delete(mdl.idxHash, idx)
+ for i := idx; i < len(mdl.data); i++ {
+ shifted := mdl.idxHash[i+1]
+ mdl.hashIdx[shifted] = i
+ mdl.idxHash[i] = shifted
+ delete(mdl.idxHash, i+1)
+ }
return nil
}
return errors.WrapE(InvalidIndex, errors.Errorf("index '%s' out of range", k))
}
-/*
-Filter filters elements of the data using a callback function and returns
-the result.
-*/
-func (mdl *Model) Filter(callback func(stdModel.Value) stdModel.Model) stdModel.Model {
- return mdl
+// Filter filters elements of the data using a callback function and returns
+// the result.
+func (mdl *Model) Filter(callback func(stdModel.Value) bool) stdModel.Model {
+ result := New(mdl.GetType(), nil)
+ mdl.mux.Lock()
+ defer mdl.mux.Unlock()
+ for i := 0; i < len(mdl.data); i++ {
+ v := toValue(mdl.data[i])
+ if callback(v) {
+ if stdModel.ModelTypeHash == mdl.GetType() {
+ result.Set(mdl.idxHash[i], v)
+ } else {
+ result.Push(v)
+ }
+ }
+ }
+ return result
}
-/*
-Get returns the specified data value in this model.
-*/
+// Get returns the specified data value in this model.
func (mdl *Model) Get(key any) (stdModel.Value, error) {
if stdModel.ModelTypeHash == mdl.GetType() {
var ok bool
@@ -115,37 +124,52 @@ func (mdl *Model) Get(key any) (stdModel.Value, error) {
case int, int8, int16, int32, int64:
mdl.mux.Lock()
defer mdl.mux.Unlock()
- if key.(int) >= int(len(mdl.data)) {
- return nil, errors.WrapE(InvalidIndex, errors.Errorf("invalid index '%d'", key.(int)))
+ k := cast.To[int](key)
+ if k < 0 || k >= len(mdl.data) {
+ return nil, errors.WrapE(InvalidIndex, errors.Errorf("invalid index '%d'", k))
}
- ret := mdl.data[key.(int)]
- return &Value{ret}, nil
+ return &Value{mdl.data[k]}, nil
default:
return nil, errors.WrapE(InvalidIndexType, errors.Errorf("key '%v' must be an integer", key))
}
}
-/*
-GetID returns returns this model's id.
-*/
+// GetData returns the current data set and indexes.
+func (mdl *Model) GetData() ([]any, map[string]int, map[int]string) {
+ mdl.mux.Lock()
+ defer mdl.mux.Unlock()
+ data := make([]any, len(mdl.data))
+ copy(data, mdl.data)
+ hashIdx := make(map[string]int, len(mdl.hashIdx))
+ for k, v := range mdl.hashIdx {
+ hashIdx[k] = v
+ }
+ idxHash := make(map[int]string, len(mdl.idxHash))
+ for k, v := range mdl.idxHash {
+ idxHash[k] = v
+ }
+ return data, hashIdx, idxHash
+}
+
+// GetID returns this model's id.
func (mdl *Model) GetID() any {
return mdl.id
}
-/*
-GetType returns the model type.
-*/
+// GetType returns the model type.
func (mdl *Model) GetType() stdModel.ModelType {
return mdl.typ
}
-/*
-Has tests to see of a specified data element exists in this model.
-*/
+// Has tests to see if a specified data element exists in this model.
func (mdl *Model) Has(key any) bool {
if stdModel.ModelTypeList == mdl.GetType() {
- if k, ok := key.(int); ok && k < len(mdl.data) {
- return true
+ switch key.(type) {
+ case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64:
+ k := cast.To[int](key)
+ if k >= 0 && k < len(mdl.data) {
+ return true
+ }
}
} else if kstr, ok := key.(string); ok {
if _, ok := mdl.hashIdx[kstr]; ok {
@@ -155,69 +179,136 @@ func (mdl *Model) Has(key any) bool {
return false
}
-/*
-Lock marks this model as read-only.
-*/
+// Lock marks this model as read-only.
func (mdl *Model) Lock() {
mdl.locked = true
}
-/*
-Map applies a callback to all elements in this model and returns the result.
-*/
-func (mdl *Model) Map(callback func(stdModel.Value) stdModel.Model) stdModel.Model {
- return nil
+// Map applies a callback to all elements in this model and returns the result.
+func (mdl *Model) Map(callback func(stdModel.Value) stdModel.Value) stdModel.Model {
+ result := New(mdl.GetType(), nil)
+ mdl.mux.Lock()
+ defer mdl.mux.Unlock()
+ for i := 0; i < len(mdl.data); i++ {
+ v := toValue(mdl.data[i])
+ mapped := callback(v)
+ if stdModel.ModelTypeHash == mdl.GetType() {
+ result.Set(mdl.idxHash[i], mapped)
+ } else {
+ result.Push(mapped)
+ }
+ }
+ return result
}
-/*
-Merge merges data from any Model into this Model.
-*/
-func (mdl *Model) Merge(model stdModel.Model) error {
+// Merge merges data from any Model into this Model.
+func (mdl *Model) Merge(incoming stdModel.Model) error {
+ if mdl.locked {
+ return errors.WrapE(ReadOnlyProperty, errors.Errorf("model is locked"))
+ }
+
+ inData, inHashIdx, _ := incoming.GetData()
+
+ switch mdl.GetType() {
+ case stdModel.ModelTypeHash:
+ if incoming.GetType() == stdModel.ModelTypeHash {
+ // hash into hash: incoming wins; merge nested models recursively
+ for key, inIdx := range inHashIdx {
+ inVal := inData[inIdx]
+ mdl.mux.Lock()
+ myIdx, exists := mdl.hashIdx[key]
+ var myVal any
+ if exists {
+ myVal = mdl.data[myIdx]
+ }
+ mdl.mux.Unlock()
+ if exists {
+ myModel, myIsModel := asModel(myVal)
+ inModel, inIsModel := asModel(inVal)
+ if myIsModel && inIsModel {
+ if err := myModel.Merge(inModel); err != nil {
+ return err
+ }
+ continue
+ }
+ }
+ if err := mdl.Set(key, inVal); err != nil {
+ return err
+ }
+ }
+ } else {
+ // list into hash: string-cast indices become keys
+ for i, v := range inData {
+ if err := mdl.Set(cast.To[string](i), v); err != nil {
+ return err
+ }
+ }
+ }
+
+ case stdModel.ModelTypeList:
+ if incoming.GetType() == stdModel.ModelTypeList {
+ // list into list: append
+ for _, v := range inData {
+ if err := mdl.Push(v); err != nil {
+ return err
+ }
+ }
+ } else {
+ // hash into list: append values in insertion order, ignore keys
+ for i := 0; i < len(inData); i++ {
+ if err := mdl.Push(inData[i]); err != nil {
+ return err
+ }
+ }
+ }
+ }
+
return nil
}
-/*
-Push a value to the end of the internal data store.
-*/
+// Push a value to the end of the internal data store.
func (mdl *Model) Push(value any) error {
- if raw, ok := value.(stdModel.Value); ok {
- value = raw
+ if mdl.locked {
+ return errors.WrapE(ReadOnlyProperty, errors.Errorf("model is locked"))
}
-
// stdModel.ModelTypeList only
if stdModel.ModelTypeList != mdl.GetType() {
return errors.WrapE(InvalidMethodContext, errors.Errorf("Push() is only valid for stdModel.ModelTypeList model types"))
}
mdl.mux.Lock()
- mdl.data = append(mdl.data, &Value{value})
+ mdl.data = append(mdl.data, toValue(value))
mdl.mux.Unlock()
return nil
}
-/*
-Reduce iteratively reduces the data to a single value using a callback
-function and returns the result.
-*/
-func (mdl *Model) Reduce(callback func(stdModel.Value) bool) stdModel.Value {
- return nil
-}
-
-/*
-Reverse reverses the order of the data store.
-*/
-func (mdl *Model) Reverse() {
+// Reduce iteratively reduces the data to a single value using a callback
+// function and returns the result.
+//
+// The callback takes two stdModel.Value arguments: carry (the result of the
+// previous iteration, or the first element on the first iteration) and cur
+// (the current element). The callback returns a stdModel.Value which becomes
+// the carry for the next iteration. After all iterations, Reduce returns the
+// final carry value. Returns nil for an empty model.
+func (mdl *Model) Reduce(callback func(carry, cur stdModel.Value) stdModel.Value) stdModel.Value {
+ mdl.mux.Lock()
+ defer mdl.mux.Unlock()
+ if len(mdl.data) == 0 {
+ return nil
+ }
+ var carry stdModel.Value = toValue(mdl.data[0])
+ for i := 1; i < len(mdl.data); i++ {
+ carry = callback(carry, toValue(mdl.data[i]))
+ }
+ return carry
}
-/*
-Set stores a value in the internal data store. All values must be identified
-by key.
-*/
+// Set stores a value in the internal data store. All values must be identified
+// by key.
func (mdl *Model) Set(key any, value any) error {
- if raw, ok := value.(stdModel.Value); ok {
- value = raw
+ if mdl.locked {
+ return errors.WrapE(ReadOnlyProperty, errors.Errorf("model is locked"))
}
-
// Hash model
if stdModel.ModelTypeHash == mdl.GetType() {
// hash keys are always strings
@@ -227,10 +318,10 @@ func (mdl *Model) Set(key any, value any) error {
if _, ok := mdl.hashIdx[idx]; !ok {
mdl.hashIdx[idx] = len(mdl.data)
mdl.idxHash[len(mdl.data)] = idx
- mdl.data = append(mdl.data, value)
+ mdl.data = append(mdl.data, toValue(value))
return nil
}
- mdl.data[mdl.hashIdx[idx]] = value
+ mdl.data[mdl.hashIdx[idx]] = toValue(value)
return nil
}
@@ -238,37 +329,40 @@ func (mdl *Model) Set(key any, value any) error {
switch key.(type) {
case int, int8, int16, int32, int64,
uint, uint8, uint16, uint32, uint64:
- k := key.(int)
+ k := cast.To[int](key)
mdl.mux.Lock()
defer mdl.mux.Unlock()
if k >= len(mdl.data) || k < 0 {
return errors.WrapE(InvalidIndex, errors.Errorf("invalid index '%d'", k))
}
- mdl.data[k] = value
+ mdl.data[k] = toValue(value)
return nil
default:
- return errors.WrapE(InvalidIndexType, errors.Errorf("key '%v' is must be an integer", key))
+ return errors.WrapE(InvalidIndexType, errors.Errorf("key '%v' must be an integer", key))
}
}
-/*
-SetID sets this Model's identifier property.
-*/
-func (mdl *Model) SetID(id any) {
+// SetID sets this Model's identifier property.
+func (mdl *Model) SetID(id any) error {
+ if mdl.locked {
+ return errors.WrapE(ReadOnlyProperty, errors.Errorf("model is locked"))
+ }
mdl.id = id
+ return nil
}
-/*
-SetData replaces the current data stored in the model with the provided
-data.
-*/
+// SetData replaces the current data stored in the model with the provided data.
func (mdl *Model) SetData(data any) error {
+ if mdl.locked {
+ return errors.WrapE(ReadOnlyProperty, errors.Errorf("model is locked"))
+ }
if stdModel.ModelTypeList == mdl.GetType() {
d, ok := data.([]any)
if !ok {
return errors.WrapE(InvalidDataSet, errors.Errorf("invalid data set for list model"))
}
mdl.data = d
+ return nil
}
d, ok := data.(map[string]any)
@@ -285,14 +379,32 @@ func (mdl *Model) SetData(data any) error {
return nil
}
-/*
-SetType sets the model type. If any data is stored in this model, this
-property becomes read-only.
-*/
+// SetType sets the model type. If any data is stored in this model, this
+// property becomes read-only.
func (mdl *Model) SetType(typ stdModel.ModelType) error {
+ if mdl.locked {
+ return errors.WrapE(ReadOnlyProperty, errors.Errorf("model is locked"))
+ }
if len(mdl.data) > 0 {
return errors.WrapE(ReadOnlyProperty, errors.Errorf("model is not empty, type cannot be modified"))
}
mdl.typ = typ
return nil
}
+
+// toValue wraps v in a *Value, or returns it directly if it is already one.
+func toValue(v any) *Value {
+ if val, ok := v.(*Value); ok && val != nil {
+ return val
+ }
+ return &Value{v}
+}
+
+// asModel extracts a stdModel.Model from v, unwrapping a *Value if necessary.
+func asModel(v any) (stdModel.Model, bool) {
+ if val, ok := v.(*Value); ok {
+ v = val.data
+ }
+ m, ok := v.(stdModel.Model)
+ return m, ok
+}
diff --git a/model.importer.go b/model.importer.go
index c1464b8..8e219ed 100644
--- a/model.importer.go
+++ b/model.importer.go
@@ -1,55 +1,80 @@
package model
import (
+ "github.com/bdlm/errors/v2"
stdModel "github.com/bdlm/std/v2/model"
stdSorter "github.com/bdlm/std/v2/sorter"
)
-func importMap(data map[string]interface{}, node *Model) *Model {
+func importMap(data map[string]any, node *Model) (*Model, error) {
for k, v := range data {
switch typedV := v.(type) {
- case map[string]interface{}:
- n := New(stdModel.ModelTypeHash)
- node.Set(k, importMap(typedV, n))
- case []interface{}:
- n := New(stdModel.ModelTypeList)
- node.Set(k, importSlice(typedV, n))
- default:
- if stdModel.ModelTypeHash == node.GetType() {
- node.Set(k, v)
+ case map[string]any:
+ n := New(stdModel.ModelTypeHash, nil)
+ child, err := importMap(typedV, n)
+ if err != nil {
+ return nil, errors.Wrap(err, "failed to import nested map at key '%s'", k)
+ }
+ if err := node.Set(k, child); err != nil {
+ return nil, errors.Wrap(err, "failed to set key '%s'", k)
+ }
+ case []any:
+ n := New(stdModel.ModelTypeList, nil)
+ child, err := importSlice(typedV, n)
+ if err != nil {
+ return nil, errors.Wrap(err, "failed to import nested slice at key '%s'", k)
}
- if stdModel.ModelTypeList == node.GetType() {
- node.Push(v)
+ if err := node.Set(k, child); err != nil {
+ return nil, errors.Wrap(err, "failed to set key '%s'", k)
+ }
+ default:
+ if err := node.Set(k, v); err != nil {
+ return nil, errors.Wrap(err, "failed to set key '%s'", k)
}
}
}
- node.Sort(stdSorter.SortByKey)
- return node
+ if err := node.Sort(stdSorter.SortByKey); err != nil {
+ return nil, errors.Wrap(err, "failed to sort by key")
+ }
+ return node, nil
}
-func importSlice(data []interface{}, node *Model) *Model {
- for _, v := range data {
+func importSlice(data []any, node *Model) (*Model, error) {
+ for i, v := range data {
switch typedV := v.(type) {
- case map[string]interface{}:
- n := New(stdModel.ModelTypeHash)
- node.Push(importMap(typedV, n))
- case []interface{}:
- n := New(stdModel.ModelTypeList)
- node.Push(importSlice(typedV, n))
+ case map[string]any:
+ n := New(stdModel.ModelTypeHash, nil)
+ child, err := importMap(typedV, n)
+ if err != nil {
+ return nil, errors.Wrap(err, "failed to import nested map at index '%d'", i)
+ }
+ if err := node.Push(child); err != nil {
+ return nil, errors.Wrap(err, "failed to push at index '%d'", i)
+ }
+ case []any:
+ n := New(stdModel.ModelTypeList, nil)
+ child, err := importSlice(typedV, n)
+ if err != nil {
+ return nil, errors.Wrap(err, "failed to import nested slice at index '%d'", i)
+ }
+ if err := node.Push(child); err != nil {
+ return nil, errors.Wrap(err, "failed to push at index '%d'", i)
+ }
default:
- node.Push(v)
+ if err := node.Push(v); err != nil {
+ return nil, errors.Wrap(err, "failed to push at index '%d'", i)
+ }
}
}
- node.Sort(stdSorter.SortByKey)
- return node
+ return node, nil
}
-func (mdl *Model) importData(data interface{}) *Model {
+func (mdl *Model) importData(data any) (*Model, error) {
switch typedData := data.(type) {
- case map[string]interface{}:
+ case map[string]any:
return importMap(typedData, mdl)
- case []interface{}:
+ case []any:
return importSlice(typedData, mdl)
}
- return nil
+ return nil, nil
}
diff --git a/model.iterator.go b/model.iterator.go
index c7a8cae..b16a12d 100644
--- a/model.iterator.go
+++ b/model.iterator.go
@@ -1,18 +1,17 @@
package model
import (
+ "github.com/bdlm/cast/v2"
"github.com/bdlm/errors/v2"
stdModel "github.com/bdlm/std/v2/model"
)
-/*
-Cur implements stdModel.Iterator.
-
-Cur reads the key and value at the current cursor postion into pK and pV
-respectively. Cur will return false if no iteration has begun, including
-following calls to Reset.
-*/
-func (mdl *Model) Cur(pK, pV *interface{}) bool {
+// Cur implements stdModel.Iterator.
+//
+// Cur reads the key and value at the current cursor position into pK and pV
+// respectively. Cur will return false if no iteration has begun, including
+// following calls to Reset.
+func (mdl *Model) Cur(pK, pV *any) bool {
if mdl.pos < 0 || mdl.pos >= len(mdl.data) {
return false
}
@@ -30,16 +29,14 @@ func (mdl *Model) Cur(pK, pV *interface{}) bool {
return true
}
-/*
-Next implements stdModel.Iterator.
-
-Next moves the cursor forward one position before reading the key and value
-at the cursor position into pK and pV respectively. If data is available at
-that position and was written to pK and pV then Next returns true, else
-false to signify the end of the data and resets the cursor postion to the
-beginning of the data set (-1).
-*/
-func (mdl *Model) Next(pK, pV *interface{}) bool {
+// Next implements stdModel.Iterator.
+//
+// Next moves the cursor forward one position before reading the key and value
+// at the cursor position into pK and pV respectively. If data is available at
+// that position and was written to pK and pV then Next returns true, else
+// false to signify the end of the data and resets the cursor position to the
+// beginning of the data set (-1).
+func (mdl *Model) Next(pK, pV *any) bool {
mdl.mux.Lock()
mdl.pos++
@@ -64,15 +61,13 @@ func (mdl *Model) Next(pK, pV *interface{}) bool {
return true
}
-/*
-Prev implements stdModel.Iterator.
-
-Prev moves the cursor backward one position before reading the key and value
-at the cursor position into pK and pV respectively. If data is available at
-that position and was written to pK and pV then Prev returns true, else
-false to signify the beginning of the data.
-*/
-func (mdl *Model) Prev(pK, pV *interface{}) bool {
+// Prev implements stdModel.Iterator.
+//
+// Prev moves the cursor backward one position before reading the key and value
+// at the cursor position into pK and pV respectively. If data is available at
+// that position and was written to pK and pV then Prev returns true, else
+// false to signify the beginning of the data.
+func (mdl *Model) Prev(pK, pV *any) bool {
mdl.mux.Lock()
mdl.pos--
@@ -96,37 +91,39 @@ func (mdl *Model) Prev(pK, pV *interface{}) bool {
return true
}
-/*
-Reset implements stdModel.Iterator.
-
-Reset sets the iterator cursor position.
-*/
+// Reset implements stdModel.Iterator.
+//
+// Reset sets the iterator cursor to the position before the first element.
func (mdl *Model) Reset() {
mdl.pos = -1
}
-/*
-Seek implements stdModel.Iterator.
-
-Seek sets the iterator cursor position.
-*/
-func (mdl *Model) Seek(pos interface{}) error {
+// Seek implements stdModel.Iterator.
+//
+// Seek sets the iterator cursor to the specified position so that Cur returns
+// the element at that position.
+func (mdl *Model) Seek(pos any) error {
// List model
if stdModel.ModelTypeList == mdl.GetType() {
- idx := pos.(int)
+ idx, err := cast.ToE[int](pos)
+ if err != nil {
+ return errors.WrapE(InvalidIndexType, errors.Errorf("position '%v' must be an integer", pos))
+ }
+ if idx < 0 {
+ return errors.WrapE(InvalidIndex, errors.Errorf("invalid index '%d'", idx))
+ }
if idx >= len(mdl.data) {
return errors.WrapE(InvalidIndex, errors.Errorf("the specified position '%d' is beyond the end of the data", idx))
- } else if idx < 0 {
- return errors.WrapE(InvalidIndex, errors.Errorf("invalid index '%d'", idx))
}
- mdl.pos = idx - 1
+ mdl.pos = idx
return nil
}
// Hash model
hashKey := pos.(string)
if idx, ok := mdl.hashIdx[hashKey]; ok {
- mdl.pos = idx - 1
+ mdl.pos = idx
+ return nil
}
return errors.WrapE(InvalidIndex, errors.Errorf("the specified position '%s' does not exist", hashKey))
}
diff --git a/model.marshaler.go b/model.marshaler.go
index aa7c5a3..011f685 100644
--- a/model.marshaler.go
+++ b/model.marshaler.go
@@ -7,45 +7,42 @@ import (
stdModel "github.com/bdlm/std/v2/model"
)
-/*
-MarshalJSON implements json.Marshaler.
-*/
+// MarshalJSON implements json.Marshaler.
func (mdl *Model) MarshalJSON() ([]byte, error) {
+ mdl.mux.Lock()
+ defer mdl.mux.Unlock()
if stdModel.ModelTypeList == mdl.GetType() {
return json.Marshal(mdl.data)
}
- d := map[string]interface{}{}
+ d := map[string]any{}
for k, v := range mdl.data {
d[mdl.idxHash[k]] = v
}
return json.Marshal(d)
}
-/*
-MarshalModel implements Marshaler.
-*/
+// MarshalModel implements Marshaler.
func (mdl *Model) MarshalModel() ([]byte, error) {
return mdl.MarshalJSON()
}
-/*
-UnmarshalJSON implements json.Unmarshaler.
-*/
+// UnmarshalJSON implements json.Unmarshaler.
func (mdl *Model) UnmarshalJSON(jsn []byte) error {
- var data interface{}
+ var data any
- err := json.Unmarshal(jsn, &data)
- if nil != err {
+ if err := json.Unmarshal(jsn, &data); err != nil {
return errors.Wrap(err, "unmarshaling failed")
}
- mdl.importData(data)
-
+ if _, err := mdl.importData(data); err != nil {
+ return errors.Wrap(err, "import failed")
+ }
return nil
}
-/*
-UnmarshalModel implements Marshaler.
-*/
-func (mdl *Model) UnmarshalModel() ([]byte, error) {
- return mdl.MarshalJSON()
+// UnmarshalModel implements Unmarshaler.
+func (mdl *Model) UnmarshalModel(bytes []byte) error {
+ if string(bytes) == "null" {
+ return nil
+ }
+ return mdl.UnmarshalJSON(bytes)
}
diff --git a/model.sorter.go b/model.sorter.go
index ec2b06a..6237d74 100644
--- a/model.sorter.go
+++ b/model.sorter.go
@@ -1,38 +1,180 @@
package model
import (
+ "reflect"
"sort"
+ "github.com/bdlm/cast/v2"
+ "github.com/bdlm/errors/v2"
stdModel "github.com/bdlm/std/v2/model"
stdSorter "github.com/bdlm/std/v2/sorter"
)
-/*
-Sort sorts the model data.
-*/
+// Len returns the number of items stored in this model.
+func (mdl *Model) Len() int {
+ return len(mdl.data)
+}
+
+// Reverse reverses the order of the data store.
+func (mdl *Model) Reverse() error {
+ if mdl.locked {
+ return errors.WrapE(ReadOnlyProperty, errors.Errorf("model is locked"))
+ }
+ mdl.mux.Lock()
+ defer mdl.mux.Unlock()
+
+ n := len(mdl.data)
+ for i, j := 0, n-1; i < j; i, j = i+1, j-1 {
+ mdl.data[i], mdl.data[j] = mdl.data[j], mdl.data[i]
+ }
+ if stdModel.ModelTypeHash == mdl.GetType() {
+ newHashIdx := make(map[string]int, n)
+ newIdxHash := make(map[int]string, n)
+ for oldIdx, key := range mdl.idxHash {
+ newIdx := n - 1 - oldIdx
+ newHashIdx[key] = newIdx
+ newIdxHash[newIdx] = key
+ }
+ mdl.hashIdx = newHashIdx
+ mdl.idxHash = newIdxHash
+ }
+ return nil
+}
+
+// Sort sorts the model data by the specified flag.
func (mdl *Model) Sort(flag stdSorter.SortFlag) error {
- data := []interface{}{}
- hashIdx := map[string]int{}
- idxHash := map[int]string{}
- switch flag {
- case stdSorter.SortByKey:
+ if mdl.locked {
+ return errors.WrapE(ReadOnlyProperty, errors.Errorf("model is locked"))
+ }
+
+ if flag&stdSorter.SortByKey != 0 {
if stdModel.ModelTypeHash == mdl.GetType() {
- order := []string{}
- for _, v := range mdl.idxHash {
- order = append(order, v)
+ keys := make([]string, 0, len(mdl.idxHash))
+ for _, k := range mdl.idxHash {
+ keys = append(keys, k)
}
- sort.Strings(order)
- for _, v := range order {
- hashIdx[v] = len(data)
- idxHash[len(data)] = v
- data = append(data, mdl.data[mdl.hashIdx[v]])
+ sort.Strings(keys)
+ mdl.data, mdl.hashIdx, mdl.idxHash = rebuildHashIndexes(keys, mdl)
+ }
+ // list: integer keys are always positional, SortByKey is a no-op
+ }
+
+ if flag&stdSorter.SortByValue != 0 {
+ compare := stratifiedLess
+ if flag&stdSorter.SortAsString != 0 {
+ compare = stringLess
+ }
+
+ if stdModel.ModelTypeHash == mdl.GetType() {
+ keys := make([]string, 0, len(mdl.idxHash))
+ for _, k := range mdl.idxHash {
+ keys = append(keys, k)
}
- mdl.data = data
- mdl.hashIdx = hashIdx
- mdl.idxHash = idxHash
+ sort.SliceStable(keys, func(i, j int) bool {
+ return compare(mdl.data[mdl.hashIdx[keys[i]]], mdl.data[mdl.hashIdx[keys[j]]])
+ })
+ mdl.data, mdl.hashIdx, mdl.idxHash = rebuildHashIndexes(keys, mdl)
}
+
if stdModel.ModelTypeList == mdl.GetType() {
+ sort.SliceStable(mdl.data, func(i, j int) bool {
+ return compare(mdl.data[i], mdl.data[j])
+ })
+ }
+ }
+
+ if flag&stdSorter.SortReverse != 0 {
+ if err := mdl.Reverse(); err != nil {
+ return errors.Wrap(err, "failed to reverse data")
}
}
+
return nil
}
+
+// modelLen returns the number of elements in a Model, or 0 for other types.
+func modelLen(v any) int {
+ if m, ok := v.(stdModel.Model); ok {
+ d, _, _ := m.GetData()
+ return len(d)
+ }
+ return 0
+}
+
+// rebuildHashIndexes rebuilds hashIdx and idxHash from an ordered key slice
+// after the data slice has been repopulated in that order.
+func rebuildHashIndexes(keys []string, src *Model) ([]any, map[string]int, map[int]string) {
+ data := make([]any, 0, len(keys))
+ hashIdx := make(map[string]int, len(keys))
+ idxHash := make(map[int]string, len(keys))
+ for _, k := range keys {
+ hashIdx[k] = len(data)
+ idxHash[len(data)] = k
+ data = append(data, src.data[src.hashIdx[k]])
+ }
+ return data, hashIdx, idxHash
+}
+
+// stratifiedLess compares two values using type-stratified ordering:
+// Model/*Model < nil < bool < numeric < string < other. Within each bucket,
+// values are compared naturally (bool: false
Date: Sat, 23 May 2026 03:23:29 -0600
Subject: [PATCH 2/4] tests, cleanup, documentation
---
.gitignore | 7 +-
CHANGELOG.md | 166 ++++
README.md | 555 ++++++++++-
errors.go | 43 +-
examples_test.go | 1109 +++++++++++++++++++++-
go.mod | 2 +-
model.go | 602 +++++++++---
model.importer.go | 47 +-
model.iterator.go | 128 ++-
model.marshaler.go | 67 +-
model.sorter.go | 199 +++-
model.types.go | 88 +-
model_test.go | 2208 ++++++++++++++++++++++++++++++++++++++++++--
value.go | 106 ++-
14 files changed, 4962 insertions(+), 365 deletions(-)
create mode 100644 CHANGELOG.md
diff --git a/.gitignore b/.gitignore
index 3d8971c..9d18a33 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,11 +1,10 @@
/vendor
-_*
-go.sum
+vendor/
go.sum
go.work
-vendor/
-_*
+go.work.sum
benchmark*
+_*
.claude*
claude*
CLAUDE*
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..300ca3e
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,166 @@
+All notable changes to this project will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+- **Major**: backwards incompatible package updates
+- **Minor**: feature additions, removal of deprecated features
+- **Patch**: bug fixes, backward compatible protobuf model changes, etc.
+
+# v1.0.0 - 2026-05-23
+
+Initial stable release of `github.com/bdlm/model`.
+
+## Overview
+
+`bdlm/model` is a generic, concurrent-safe data container for Go. A single
+`Model` holds either a **hash** (string-keyed, ordered map) or a **list**
+(integer-indexed array) of arbitrary values. Values are wrapped in a `Value`
+type that provides typed accessors. Nested models are supported at arbitrary
+depth; JSON unmarshaling builds the nested structure automatically.
+
+## Core API
+
+### Construction
+
+- `New(modelType, data) (*Model, error)` — creates a HASH or LIST model,
+ optionally pre-populated from `map[string]any` or `[]any`. Hash models built
+ via `New` or `UnmarshalJSON` are automatically sorted by key.
+
+### CRUD
+
+- `Set(key, value) error` — store a value by key; hash keys accept any type
+ and are cast to `string` via `bdlm/cast`.
+- `Get(key) (Value, error)` — retrieve a value by key or index.
+- `Has(key) bool` — check existence without retrieving.
+- `Push(value) error` — append to a LIST model.
+- `Delete(key) error` — remove by key or index; hash index is rebuilt
+ atomically and the backing-array slot is zeroed to release the GC reference.
+- `Len() int` — number of elements.
+
+### Metadata
+
+- `GetType() / SetType()` — model type (HASH or LIST); type can only be
+ changed while the model is empty.
+- `GetID() / SetID()` — arbitrary model identifier; used as the primary sort
+ key when comparing nested models.
+- `GetData() ([]any, map[string]int, map[int]string)` — returns isolated
+ copies of the internal data slice and both index maps.
+- `SetData(data) error` — replaces the entire data store atomically.
+
+### Locking
+
+- `Lock()` — makes the model permanently read-only; there is no Unlock.
+ All write operations return `ReadOnlyModel` on a locked model.
+
+### Iteration
+
+Bidirectional cursor iterator:
+
+- `Next(pK, pV *any) bool` — advance; resets and returns false at end.
+- `Prev(pK, pV *any) bool` — retreat; clamps to -1 and returns false at start.
+- `Cur(pK, pV *any) bool` — read current position without moving.
+- `Seek(pos any) error` — jump to a key (HASH) or index (LIST).
+- `Reset()` — reset cursor to before the first element.
+
+The cursor is reset to -1 on any successful mutation (Delete, SetData, Sort,
+Reverse) and is left unchanged on failed operations.
+
+### Sorting
+
+`Sort(SortFlag)` sorts in place. Uses `sync.RWMutex` write lock; does not reset
+the cursor unless data ordering actually changes.
+
+Sort flags (`github.com/bdlm/std/v2/sorter`):
+
+| Flag | Value | Meaning |
+|------|-------|---------|
+| `SortByValue` | `0` | Zero-value default; `Sort(SortByValue)` is a no-op. Trigger value sorting with `SortAsc`, `SortDesc`, or `SortAsString`. |
+| `SortByKey` | `1` | Alphabetical key sort (HASH), or no-op on LIST unless combined with `SortAsString`. |
+| `SortAsc` | `2` | Ascending; triggers value sorting when used without `SortByKey`. |
+| `SortDesc` | `4` | Descending; triggers value sorting when used without `SortByKey`. |
+| `SortAsString` | `8` | String comparison via cast; triggers value sorting when used without `SortByKey`. |
+| `SortReverse` | `16` | Reverse the final result after all other sorting. |
+
+Only one invalid combination: `SortAsc | SortDesc`.
+
+Type-stratified value ordering (ascending): `*Model < nil < bool < numeric < string < other`.
+
+- `Reverse() error` — reverse in place; rebuilds hash index maps correctly.
+
+### Functional Operations
+
+All three operate on a snapshot taken before the callback is invoked, so
+callbacks may safely read or write the model without deadlocking.
+
+- `Filter(func(Value) bool) Model` — returns a new model of the same type
+ containing only elements for which the callback returns true.
+- `Map(func(Value) Value) Model` — returns a new model with each element
+ replaced by the callback's return value.
+- `Reduce(func(carry, cur Value) Value) Value` — iteratively reduces to a
+ single value; the first element is the initial carry. Returns nil for an
+ empty model.
+
+### Merge
+
+`Merge(incoming Model) error` merges all values from `incoming` into the
+receiver.
+
+| Receiver | Incoming | Behavior |
+|----------|----------|----------|
+| HASH | HASH | Incoming wins on conflict; both-model keys are merged recursively |
+| HASH | LIST | List indices cast to string become hash keys |
+| LIST | LIST | Incoming elements appended |
+| LIST | HASH | Hash values appended in insertion order |
+
+Self-merge returns `InvalidMethodContext`.
+
+### JSON
+
+- `MarshalJSON() / UnmarshalJSON()` — standard `encoding/json` interfaces.
+ `UnmarshalJSON` builds into a temporary model and swaps internals under a
+ single write lock, so concurrent readers never observe an empty intermediate
+ state. Repeated calls replace data rather than accumulating.
+- `MarshalModel() / UnmarshalModel()` — `Marshaler`/`Unmarshaler` interface
+ methods. `UnmarshalModel` treats the JSON `null` literal as a no-op.
+
+## Value Accessors
+
+`Value` wraps `any` and exposes:
+`Bool`, `Int`, `Float`, `Float32`, `Float64`, `String`, `Value` (raw),
+`Model` (nested model), `List` (`[]stdModel.Value`), `Map` (`map[string]stdModel.Value`).
+
+All numeric/string conversions use `bdlm/cast`; they return an error when the
+conversion is not possible.
+
+Package-level generics `To[T]` and `ToE[T]` expose the cast helpers directly.
+
+## Error Sentinels
+
+All errors are compatible with the standard `errors.Is` function:
+
+| Sentinel | Condition |
+|----------|-----------|
+| `InvalidIndex` | Key or index does not exist |
+| `InvalidIndexType` | Wrong key type for this model type |
+| `InvalidMethodContext` | Method invalid in context (Push on hash, self-merge) |
+| `ReadOnlyModel` | Model is locked or SetType called on non-empty model |
+| `InvalidDataSet` | SetData type does not match model type |
+| `InvalidSortFlagCombination` | SortAsc and SortDesc combined |
+
+## Concurrency
+
+All public methods are safe for concurrent use. The implementation uses
+`sync.RWMutex` — read operations (`Get`, `Has`, `Len`, `Cur`, `GetData`,
+`MarshalJSON`) acquire a read lock and run concurrently; write operations
+acquire an exclusive lock. The `locked` flag and model type are stored as
+`sync/atomic` values. The double-checked locking pattern is used on all write
+paths to close the TOCTOU window between the pre-lock check and lock
+acquisition.
+
+## Dependencies
+
+| Package | Role |
+|---------|------|
+| `bdlm/cast/v2` | Type conversion for value accessors and key coercion |
+| `bdlm/errors/v2` | Structured error wrapping with sentinel support |
+| `bdlm/std/v2` | Interface definitions: `Model`, `Value`, `Iterator`, `Sorter` |
diff --git a/README.md b/README.md
index c5fbd0a..6b092ef 100644
--- a/README.md
+++ b/README.md
@@ -1,10 +1,553 @@
# model
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+`bdlm/model` is a generic, type-agnostic data container for Go. A single `Model` can hold either a **hash** (string-keyed map) or a **list** (integer-indexed array) of arbitrary values, with full support for nested models, bidirectional cursor iteration, sorting, merging, functional transforms, and JSON marshaling.
+
+All public methods are safe for concurrent use.
+
+---
+
+## Installation
+
+```bash
+go get github.com/bdlm/model
+```
+
+---
+
+## Overview
+
+A `Model` stores values wrapped in a `Value` type that provides typed accessors (`Bool`, `Int`, `Float`, `String`, `Model`, etc.). Two model types are available:
+
+| Constant | Behavior | Keys |
+|----------|----------|------|
+| `model.HASH` | ordered map | `string` |
+| `model.LIST` | indexed array | `int` (0-based) |
+
+Nested structures are supported: any value can itself be a `*Model`, allowing arbitrarily deep trees. JSON unmarshaling builds this structure automatically.
+
+---
+
+## Creating a Model
+
+```go
+// Empty hash model
+mdl, err := model.New(model.HASH, nil)
+
+// Empty list model
+mdl, err := model.New(model.LIST, nil)
+
+// Pre-populated hash model — keys are sorted alphabetically on import
+mdl, err := model.New(model.HASH, map[string]any{
+ "name": "Alice",
+ "age": 30,
+})
+
+// Pre-populated list model from a slice
+mdl, err := model.New(model.LIST, []any{"one", "two", 3})
+```
+
+`New` returns an error if `data` is not `nil`, `map[string]any` (for `HASH`), or `[]any` (for `LIST`).
+
+---
+
+## CRUD Operations
+
+### Set and Get
+
+```go
+mdl, _ := model.New(model.HASH, nil)
+
+// Store values — hash keys are always strings (any type is cast via bdlm/cast)
+mdl.Set("name", "Alice")
+mdl.Set("score", 42)
+mdl.Set(100, "numeric keys are cast to string")
+
+// Retrieve a value
+val, err := mdl.Get("name")
+if err != nil {
+ // model.InvalidIndex if key does not exist
+}
+str, _ := val.String() // "Alice"
+```
+
+For list models, keys must be integer types and must already exist (use `Push` to append):
+
+```go
+lst, _ := model.New(model.LIST, nil)
+lst.Push("first")
+lst.Push("second")
+
+val, _ := lst.Get(0)
+str, _ := val.String() // "first"
+
+lst.Set(0, "updated")
+```
+
+### Push
+
+`Push` appends to a list model. It returns `InvalidMethodContext` on a hash model.
+
+```go
+lst, _ := model.New(model.LIST, nil)
+lst.Push(1)
+lst.Push(2)
+lst.Push("three")
+// list is now [1, 2, "three"]
+```
+
+### Delete
+
+```go
+// Hash — delete by string key
+mdl.Delete("name")
+
+// List — delete by integer index; remaining elements shift left
+lst.Delete(1)
+```
+
+`Delete` on a list model requires an integer key. Passing a non-integer type returns `InvalidIndexType`.
+
+### Has
+
+```go
+if mdl.Has("name") {
+ // key exists
+}
+```
+
+---
+
+## Value Accessors
+
+Every value retrieved from a model is a `Value` with typed conversion methods. All conversions use [`bdlm/cast`](https://github.com/bdlm/cast) and return an error if the conversion is not possible.
+
+```go
+val, _ := mdl.Get("key")
+
+b, err := val.Bool()
+i, err := val.Int()
+f, err := val.Float() // float64
+f32, err := val.Float32()
+f64, err := val.Float64()
+s, err := val.String()
+raw := val.Value() // untyped any
+
+// Nested model stored at this value
+nested, err := val.Model()
+```
+
+`List()` and `Map()` are low-level accessors for the uncommon case where a raw `[]stdModel.Value` or `map[string]stdModel.Value` was stored directly in the model. For nested arrays and objects, use a child `*Model` and `val.Model()` instead.
+
+```go
+list, err := val.List() // succeeds only if underlying data is []stdModel.Value
+m, err := val.Map() // succeeds only if underlying data is map[string]stdModel.Value
+```
+
+---
+
+## Iteration
+
+`Model` implements a bidirectional cursor iterator. For hash models, iteration order depends on how the model was built:
+
+- **Built via `New(HASH, map[string]any{...})` or `UnmarshalJSON`**: keys are sorted alphabetically on import.
+- **Built via sequential `Set` calls**: keys are returned in insertion order. Call `Sort(SortByKey)` to normalize.
+
+```go
+// Keys sorted alphabetically because the model was built via New with a map.
+mdl, _ := model.New(model.HASH, map[string]any{"b": 2, "a": 1})
+
+var key, val any
+for mdl.Next(&key, &val) {
+ n, _ := val.(stdModel.Value).Int()
+ fmt.Printf("%s = %d\n", key, n)
+}
+// a = 1
+// b = 2
+```
+
+### Iterator Methods
+
+| Method | Description |
+|--------|-------------|
+| `Next(pK, pV *any) bool` | Advance cursor; returns false and resets at end |
+| `Prev(pK, pV *any) bool` | Retreat cursor; returns false at beginning |
+| `Cur(pK, pV *any) bool` | Read current position without moving |
+| `Seek(pos any) error` | Jump to a specific key (hash) or index (list) |
+| `Reset()` | Reset cursor to before the first element |
+| `Len() int` | Number of elements |
+
+After `Seek(n)`, `Cur` returns element `n` and `Next` returns element `n+1`.
+
+```go
+// Reverse iteration — seek to the last element, then walk backward.
+mdl.Seek(mdl.Len() - 1) // list: seek to last index
+mdl.Seek("z") // hash: seek to key "z"
+
+var key, val any
+for mdl.Prev(&key, &val) {
+ fmt.Println(key, val.(stdModel.Value).Value())
+}
+```
+
+---
+
+## Sorting
+
+`Sort(flag)` accepts one or more `SortFlag` values combined with `|`. Invalid combinations return `InvalidSortFlagCombination`.
+
+`SortByValue` is the zero-value default (`0`). Value-based sorting is triggered by providing a direction or modifier flag **without** `SortByKey`:
+
+```go
+import "github.com/bdlm/std/v2/sorter"
+
+mdl.Sort(sorter.SortByKey) // HASH: alphabetical ascending
+mdl.Sort(sorter.SortByKey | sorter.SortDesc) // HASH: alphabetical descending
+mdl.Sort(sorter.SortAsc) // sort by value, ascending
+mdl.Sort(sorter.SortDesc) // sort by value, descending
+mdl.Sort(sorter.SortAsString) // sort by value, string comparison
+mdl.Sort(sorter.SortDesc | sorter.SortReverse) // desc then reversed = ascending
+```
+
+### Sort Flags
+
+| Flag | Value | Effect |
+|------|-------|--------|
+| `SortByValue` | `0` | Zero-value default. Value sorting is implicit; `Sort(SortByValue)` alone is a **no-op**. Trigger it with `SortAsc`, `SortDesc`, or `SortAsString`. |
+| `SortByKey` | `1` | Hash: sort keys alphabetically. List: no-op unless combined with `SortAsString`. |
+| `SortAsc` | `2` | Ascending order. Triggers value-based sorting when used without `SortByKey`. |
+| `SortDesc` | `4` | Descending order. Triggers value-based sorting when used without `SortByKey`. |
+| `SortAsString` | `8` | String comparison instead of type-stratified. Triggers value-based string sorting when used without `SortByKey`. With `SortByKey` on a list, sorts list values as strings. |
+| `SortReverse` | `16` | Reverse the final result after all other sorting is complete. |
+
+**Only one invalid combination** (returns `InvalidSortFlagCombination`):
+- `SortAsc | SortDesc` — conflicting direction flags
+
+### `SortDesc` vs `SortReverse`
+
+`SortDesc` changes the comparison direction before sorting; `SortReverse` reverses the result after sorting. They are fully composable:
+
+| Flags | Effect |
+|-------|--------|
+| `SortAsc` | ascending by value |
+| `SortDesc` | descending by value |
+| `SortAsc \| SortReverse` | ascending sorted then reversed = descending |
+| `SortDesc \| SortReverse` | descending sorted then reversed = ascending |
+
+### Type-Stratified Order
+
+When sorting by value without `SortAsString`, the ascending sort order across types is:
+
+```
+*Model < nil < false < true < (numeric) < (string, lexicographic) < other
+```
+
+Within the `*Model` bucket, models are compared by `GetID()` then by element count.
+
+### `SortByKey | SortAsString` on a List
+
+Sorts the list elements by their string representations:
+
+```go
+lst, _ := model.New(model.LIST, []any{30, 10, 200})
+lst.Sort(sorter.SortByKey | sorter.SortAsString)
+// result: [10, 200, 30] (lexicographic: "10" < "200" < "30")
+
+lst.Sort(sorter.SortByKey | sorter.SortAsString | sorter.SortDesc)
+// result: [30, 200, 10]
+```
+
+### Standalone `Reverse`
+
+```go
+mdl.Reverse() // reverses in place; maintains hash index consistency
+```
+
+---
+
+## Merging
+
+`Merge` merges all values from an incoming model into the receiver. Self-merge returns an error.
+
+```go
+base, _ := model.New(model.HASH, nil)
+base.Set("a", 1)
+base.Set("b", 2)
+
+incoming, _ := model.New(model.HASH, nil)
+incoming.Set("b", 99) // overwrites
+incoming.Set("c", 3) // new key
+
+base.Merge(incoming)
+// base is now {a:1, b:99, c:3}
+```
+
+### Merge Rules
+
+| Receiver type | Incoming type | Behavior |
+|---------------|---------------|----------|
+| HASH | HASH | Incoming wins on conflict; when both values are `*Model`, they are merged recursively |
+| HASH | LIST | List indices cast to string become hash keys |
+| LIST | LIST | Incoming elements appended |
+| LIST | HASH | Hash values appended in insertion order; keys ignored |
+
+---
+
+## Functional Operations
+
+The callback for `Filter`, `Map`, and `Reduce` is called without holding the model mutex, so it is safe for the callback to read or write the model being iterated.
+
+### Filter
+
+Returns a new model of the same type containing only elements for which the callback returns `true`. The original model is not modified.
+
+```go
+nums, _ := model.New(model.LIST, []any{1, 2, 3, 4, 5, 6})
+evens := nums.Filter(func(v stdModel.Value) bool {
+ n, _ := v.Int()
+ return n%2 == 0
+})
+// evens: [2, 4, 6]
+```
+
+### Map
+
+Returns a new model of the same type with each element replaced by the callback's return value. The original model is not modified.
+
+```go
+nums, _ := model.New(model.LIST, []any{1, 2, 3})
+doubled := nums.Map(func(v stdModel.Value) stdModel.Value {
+ n, _ := v.Int()
+ tmp, _ := model.New(model.LIST, nil)
+ tmp.Push(n * 2)
+ result, _ := tmp.Get(0)
+ return result
+})
+// doubled: [2, 4, 6]
+```
+
+### Reduce
+
+Iteratively reduces the model to a single value. The first element is used as the initial carry; the callback is first invoked with that carry and the second element. Returns `nil` for an empty model.
+
+```go
+nums, _ := model.New(model.LIST, []any{3, 1, 4, 1, 5, 9})
+maxVal := nums.Reduce(func(carry, cur stdModel.Value) stdModel.Value {
+ a, _ := carry.Int()
+ b, _ := cur.Int()
+ if b > a {
+ return cur
+ }
+ return carry
+})
+n, _ := maxVal.Int() // 9
+```
+
+---
+
+## JSON Support
+
+`Model` implements `json.Marshaler` and `json.Unmarshaler`. Nested JSON objects become child `HASH` models; nested arrays become child `LIST` models. Hash models are always sorted by key after import.
+
+```go
+mdl, _ := model.New(model.HASH, nil)
+json.Unmarshal([]byte(`{
+ "name": "Alice",
+ "scores": [95, 87, 91],
+ "address": {"city": "Portland", "state": "OR"}
+}`), &mdl)
+
+// Access top-level scalar
+val, _ := mdl.Get("name")
+name, _ := val.String() // "Alice"
+
+// Access nested list
+val, _ = mdl.Get("scores")
+nested, _ := val.Model() // *Model (LIST)
+first, _ := nested.Get(0)
+score, _ := first.Float() // 95 (JSON numbers decode as float64)
+
+// Access nested hash
+val, _ = mdl.Get("address")
+addr, _ := val.Model() // *Model (HASH)
+city, _ := addr.Get("city")
+c, _ := city.String() // "Portland"
+
+// Marshal back to JSON
+b, _ := json.Marshal(mdl)
+```
+
+### `MarshalModel` / `UnmarshalModel`
+
+The package also defines `Marshaler` and `Unmarshaler` interfaces for custom serialization:
+
+```go
+bytes, err := mdl.MarshalModel() // delegates to MarshalJSON
+err = mdl.UnmarshalModel(bytes) // delegates to UnmarshalJSON; no-op for "null"
+```
+
+---
+
+## Locking
+
+`Lock()` makes a model permanently read-only. There is no `Unlock`. All write operations (`Set`, `Push`, `Delete`, `Merge`, `Reverse`, `Sort`, `SetData`, `SetID`, `SetType`, `UnmarshalJSON`) return `ReadOnlyModel` on a locked model.
+
+```go
+mdl.Lock()
+err := mdl.Set("key", "value") // returns ReadOnlyModel error
+```
+
+---
+
+## SetData and SetType
+
+`SetData` replaces the entire data store:
+
+```go
+// Replace hash model contents
+mdl.SetData(map[string]any{"x": 1, "y": 2})
+
+// Replace list model contents
+lst.SetData([]any{10, 20, 30})
+```
+
+`SetType` changes the model type, but only while the model is empty:
+
+```go
+mdl, _ := model.New(model.HASH, nil)
+mdl.SetType(model.LIST) // OK — model is empty
+mdl.Push("item")
+mdl.SetType(model.HASH) // error: ReadOnlyModel (model is not empty)
+```
+
+---
+
+## Model Identity
+
+Every model can carry an arbitrary identifier:
+
+```go
+mdl.SetID("user-42")
+id := mdl.GetID() // any
+```
+
+IDs are used as the primary sort key when sorting a list of models by value, with element count as a tiebreaker.
+
+---
+
+## Errors
+
+All sentinel errors are exported and compatible with the standard `errors.Is` function:
+
+| Sentinel | When returned |
+|----------|--------------|
+| `InvalidIndex` | Key or index does not exist |
+| `InvalidIndexType` | Wrong key type for this model type (e.g. string key on a LIST) |
+| `InvalidMethodContext` | Operation invalid in context (e.g., `Push` on a hash, self-merge) |
+| `ReadOnlyModel` | Model is locked, or type change attempted on non-empty model |
+| `InvalidDataSet` | `SetData` called with a type incompatible with the model type |
+| `InvalidSortFlagCombination` | `SortAsc` and `SortDesc` combined |
+
+```go
+val, err := mdl.Get("missing")
+if errors.Is(err, model.InvalidIndex) {
+ // key not found
+}
+```
+
+---
+
+## Thread Safety
+
+All public methods are safe for concurrent use. Internally, `Model` uses a `sync.RWMutex` — readers (`Get`, `Has`, `Len`, `Cur`, `GetData`, `MarshalJSON`) acquire a read lock and run concurrently; writers acquire an exclusive lock.
+
+The `locked` flag and model type are stored as `sync/atomic` values, making `Lock()` and `GetType()` safe to call from any context including from inside a lock.
+
+The `Merge` operation holds the receiver's mutex for all direct writes. During recursive merges of nested models, the receiver mutex is briefly released and reacquired, so `Merge` is not fully atomic in the presence of concurrent writers on the receiver.
+
+---
+
+## Complete Example
+
+```go
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+
+ "github.com/bdlm/model"
+ "github.com/bdlm/std/v2/iterator"
+ stdModel "github.com/bdlm/std/v2/model"
+)
+
+func main() {
+ // Build a hash model from JSON. Nested objects become HASH models;
+ // nested arrays become LIST models. Hash keys are sorted alphabetically.
+ mdl, _ := model.New(model.HASH, nil)
+ json.Unmarshal([]byte(`{
+ "users": [
+ {"name": "Charlie", "age": 25},
+ {"name": "Alice", "age": 30},
+ {"name": "Bob", "age": 22}
+ ]
+ }`), &mdl)
+
+ // Get the nested list. val.Model() returns stdModel.Model.
+ val, _ := mdl.Get("users")
+ users, _ := val.Model()
+
+ // Iterate using the iterator.Iterator interface.
+ var key, v any
+ for users.(iterator.Iterator).Next(&key, &v) {
+ user, _ := v.(stdModel.Value).Model()
+ nameVal, _ := user.Get("name")
+ name, _ := nameVal.String()
+ ageVal, _ := user.Get("age")
+ age, _ := ageVal.Float() // JSON numbers decode as float64
+ fmt.Printf("%s: %d\n", name, int(age))
+ }
+ // Charlie: 25
+ // Alice: 30
+ // Bob: 22
+
+ // Filter to adults only.
+ adults := users.Filter(func(v stdModel.Value) bool {
+ user, err := v.Model()
+ if err != nil {
+ return false
+ }
+ ageVal, _ := user.Get("age")
+ age, _ := ageVal.Float()
+ return age >= 25
+ })
+ fmt.Println("Adults:", adults) // [{"age":25,...},{"age":30,...}]
+
+ // Marshal the root model back to JSON.
+ b, _ := json.Marshal(mdl)
+ fmt.Println(string(b))
+}
+```
+
+---
+
+## Dependencies
+
+| Package | Role |
+|---------|------|
+| [`bdlm/cast/v2`](https://github.com/bdlm/cast) | Type conversion for value accessors and key coercion |
+| [`bdlm/errors/v2`](https://github.com/bdlm/errors) | Structured error wrapping with sentinel support |
+| [`bdlm/std/v2`](https://github.com/bdlm/std) | Interface definitions: `Model`, `Value`, `Iterator`, `Sorter` |
diff --git a/errors.go b/errors.go
index d56dfdf..c682947 100644
--- a/errors.go
+++ b/errors.go
@@ -5,37 +5,52 @@ import (
stdErrors "github.com/bdlm/std/v2/errors"
)
-// Internal errors
+// Exported error sentinels. All errors returned by this package wrap one of
+// these values and can be tested with errors.Is:
+//
+// if errors.Is(err, model.InvalidIndex) { … }
var (
- // InvalidIndex - The specified index does not exist.
+ // InvalidIndex is returned when a key or index that does not exist in the
+ // model is used with Get, Delete, or Seek.
InvalidIndex stdErrors.Error
- // InvalidIndexType - The specified index data type is invalid for this
- // model.
+ // InvalidIndexType is returned when a key of the wrong type is used to
+ // access a model. For LIST models all keys must be integer types (int,
+ // int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64); passing
+ // any other type to Get, Delete, or Seek returns this error.
InvalidIndexType stdErrors.Error
- // InvalidMethodContext - The requested method is not valid in the
- // current context. E.g. the Push() method on hash models.
+ // InvalidMethodContext is returned when a method is called in an
+ // unsupported context. Examples: Push called on a HASH model, or Merge
+ // called with the receiver as the incoming argument (self-merge).
InvalidMethodContext stdErrors.Error
- // ReadOnlyProperty - An attempt was made to modify a read-only property.
- ReadOnlyProperty stdErrors.Error
+ // ReadOnlyModel is returned when a write operation is attempted on a model
+ // that has been made read-only. A model becomes read-only in two ways:
+ // - Lock has been called (permanent; affects all write methods).
+ // - SetType is called on a non-empty model (the type is read-only while
+ // data is present, but the lock flag itself is not set).
+ ReadOnlyModel stdErrors.Error
- // InvalidDataSet - An attempt was made to store a data set that is
- // with the model type
+ // InvalidDataSet is returned by SetData when the supplied data type is
+ // incompatible with the model type: a []any for a HASH model, or a
+ // map[string]any for a LIST model.
InvalidDataSet stdErrors.Error
- // InvalidSortFlagCombination - The specified sort flag combination is
- // invalid for this model type or in conflict with another flag.
- // E.g. SortByKey and SortByValue.
+ // InvalidSortFlagCombination is returned by Sort when mutually exclusive
+ // flags are combined. The only invalid combination is SortAsc | SortDesc.
InvalidSortFlagCombination stdErrors.Error
)
+// init initializes the package-level error sentinel variables. Sentinels are
+// constructed here rather than at var-declaration time so that the
+// errors.New calls (which establish the sentinel identity used by errors.Is)
+// run in a single, well-defined initialization pass.
func init() {
InvalidIndex = errors.New("specified index does not exist")
InvalidIndexType = errors.New("an invalid index datatype was used")
InvalidMethodContext = errors.New("a method was used in an invalid context")
- ReadOnlyProperty = errors.New("cannot update a read-only property")
+ ReadOnlyModel = errors.New("cannot update a read-only model")
InvalidDataSet = errors.New("invalid data set for model type")
InvalidSortFlagCombination = errors.New("invalid sort flag combination")
}
diff --git a/examples_test.go b/examples_test.go
index 907f036..ccd267e 100644
--- a/examples_test.go
+++ b/examples_test.go
@@ -2,17 +2,20 @@ package model_test
import (
"encoding/json"
+ "errors"
"fmt"
- "os"
- "github.com/bdlm/log/v2"
"github.com/bdlm/model"
- stdIterator "github.com/bdlm/std/v2/iterator"
+ "github.com/bdlm/std/v2/iterator"
stdModel "github.com/bdlm/std/v2/model"
+ "github.com/bdlm/std/v2/sorter"
)
+// ── Construction ──────────────────────────────────────────────────────────────
+
+// ExampleNew demonstrates creating a HASH model from JSON and iterating it.
func ExampleNew() {
- mdl := model.New(stdModel.ModelTypeHash, nil)
+ mdl, _ := model.New(model.HASH, nil)
json.Unmarshal(
[]byte(`{"key1":"value1","key2":2,"key3":["one","two","three"],"key4":{"k1":"v1","k2":"v2"}}`),
&mdl,
@@ -21,16 +24,9 @@ func ExampleNew() {
for mdl.Next(&key, &val) {
if "key3" == key.(string) || "key4" == key.(string) {
var k2, v2 any
- var m2 stdModel.Model
- m2, _ = val.(stdModel.Value).Model()
- if nil == m2 {
- data, hash, index := mdl.GetData()
- log.Debugf("\n\n\ndata: %v\nhash: %v\nindex: %v\n\n\n", data, hash, index)
- os.Exit(1)
- }
-
+ m2, _ := val.(stdModel.Value).Model()
fmt.Println(key)
- for m2.(stdIterator.Iterator).Next(&k2, &v2) {
+ for m2.(iterator.Iterator).Next(&k2, &v2) {
fmt.Println(" ", k2, v2.(stdModel.Value).Value())
}
} else {
@@ -48,3 +44,1090 @@ func ExampleNew() {
// k1 v1
// k2 v2
}
+
+// ExampleNew_withHashData demonstrates constructing a HASH model with initial
+// data. importMap automatically sorts keys alphabetically.
+func ExampleNew_withHashData() {
+ m, _ := model.New(model.HASH, map[string]any{"b": 2, "a": 1})
+ var k, v any
+ for m.Next(&k, &v) {
+ n, _ := v.(stdModel.Value).Int()
+ fmt.Printf("%s=%d\n", k, n)
+ }
+
+ // Output:
+ // a=1
+ // b=2
+}
+
+// ExampleNew_withListData demonstrates constructing a LIST model with initial
+// data. Insertion order is preserved.
+func ExampleNew_withListData() {
+ m, _ := model.New(model.LIST, []any{"x", "y", "z"})
+ var k, v any
+ for m.Next(&k, &v) {
+ s, _ := v.(stdModel.Value).String()
+ fmt.Printf("[%d]=%s\n", k, s)
+ }
+
+ // Output:
+ // [0]=x
+ // [1]=y
+ // [2]=z
+}
+
+// ── Type ──────────────────────────────────────────────────────────────────────
+
+// ExampleModel_GetType demonstrates reading the model type.
+func ExampleModel_GetType() {
+ hash, _ := model.New(model.HASH, nil)
+ list, _ := model.New(model.LIST, nil)
+ fmt.Println(hash.GetType() == model.HASH)
+ fmt.Println(list.GetType() == model.LIST)
+
+ // Output:
+ // true
+ // true
+}
+
+// ExampleModel_SetType demonstrates changing the type of an empty model.
+func ExampleModel_SetType() {
+ m, _ := model.New(model.HASH, nil)
+ err := m.SetType(model.LIST) // only succeeds on an empty model
+ fmt.Println(err)
+ fmt.Println(m.GetType() == model.LIST)
+
+ // Output:
+ //
+ // true
+}
+
+// ── Identity ──────────────────────────────────────────────────────────────────
+
+// ExampleModel_SetID demonstrates setting a model identifier.
+func ExampleModel_SetID() {
+ m, _ := model.New(model.HASH, nil)
+ m.SetID("my-model")
+ fmt.Println(m.GetID())
+
+ // Output:
+ // my-model
+}
+
+// ExampleModel_GetID demonstrates retrieving the model identifier.
+func ExampleModel_GetID() {
+ m, _ := model.New(model.HASH, nil)
+ fmt.Println(m.GetID()) // nil before any SetID call
+ m.SetID(42)
+ fmt.Println(m.GetID())
+
+ // Output:
+ //
+ // 42
+}
+
+// ── CRUD — HASH ───────────────────────────────────────────────────────────────
+
+// ExampleModel_Set demonstrates storing values in a HASH model. Hash keys are
+// always cast to string, so integer and other key types are accepted.
+func ExampleModel_Set() {
+ m, _ := model.New(model.HASH, nil)
+ m.Set("name", "Alice")
+ m.Set("age", 30)
+
+ v, _ := m.Get("name")
+ s, _ := v.String()
+ fmt.Println(s)
+
+ v, _ = m.Get("age")
+ n, _ := v.Int()
+ fmt.Println(n)
+
+ // Output:
+ // Alice
+ // 30
+}
+
+// ExampleModel_Set_update demonstrates that Set overwrites an existing key.
+func ExampleModel_Set_update() {
+ m, _ := model.New(model.HASH, nil)
+ m.Set("k", 1)
+ m.Set("k", 2)
+ v, _ := m.Get("k")
+ n, _ := v.Int()
+ fmt.Println(n)
+
+ // Output:
+ // 2
+}
+
+// ExampleModel_Get demonstrates retrieving values from HASH and LIST models.
+func ExampleModel_Get() {
+ h, _ := model.New(model.HASH, nil)
+ h.Set("color", "blue")
+ v, _ := h.Get("color")
+ s, _ := v.String()
+ fmt.Println(s)
+
+ l, _ := model.New(model.LIST, []any{10, 20, 30})
+ v, _ = l.Get(1)
+ n, _ := v.Int()
+ fmt.Println(n)
+
+ // Output:
+ // blue
+ // 20
+}
+
+// ExampleModel_Has demonstrates checking whether a key exists.
+func ExampleModel_Has() {
+ h, _ := model.New(model.HASH, nil)
+ h.Set("present", 1)
+ fmt.Println(h.Has("present"))
+ fmt.Println(h.Has("missing"))
+
+ l, _ := model.New(model.LIST, []any{1, 2, 3})
+ fmt.Println(l.Has(0))
+ fmt.Println(l.Has(5))
+
+ // Output:
+ // true
+ // false
+ // true
+ // false
+}
+
+// ExampleModel_Delete demonstrates removing a key from a HASH model and an
+// element from a LIST model.
+func ExampleModel_Delete() {
+ h, _ := model.New(model.HASH, nil)
+ h.Set("a", 1)
+ h.Set("b", 2)
+ h.Delete("a")
+ fmt.Println(h.Has("a"))
+ fmt.Println(h.Len())
+
+ l, _ := model.New(model.LIST, []any{10, 20, 30})
+ l.Delete(1) // removes element at index 1
+ fmt.Println(l.Len())
+ v, _ := l.Get(1) // former index 2 is now index 1
+ n, _ := v.Int()
+ fmt.Println(n)
+
+ // Output:
+ // false
+ // 1
+ // 2
+ // 30
+}
+
+// ── CRUD — LIST ───────────────────────────────────────────────────────────────
+
+// ExampleModel_Push demonstrates appending values to a LIST model.
+func ExampleModel_Push() {
+ m, _ := model.New(model.LIST, nil)
+ m.Push("first")
+ m.Push("second")
+ m.Push("third")
+ fmt.Println(m.Len())
+ v, _ := m.Get(2)
+ s, _ := v.String()
+ fmt.Println(s)
+
+ // Output:
+ // 3
+ // third
+}
+
+// ── Length ────────────────────────────────────────────────────────────────────
+
+// ExampleModel_Len demonstrates returning the number of elements stored.
+func ExampleModel_Len() {
+ m, _ := model.New(model.LIST, []any{1, 2, 3})
+ fmt.Println(m.Len())
+ m.Push(4)
+ fmt.Println(m.Len())
+ m.Delete(0)
+ fmt.Println(m.Len())
+
+ // Output:
+ // 3
+ // 4
+ // 3
+}
+
+// ── Data replacement ──────────────────────────────────────────────────────────
+
+// ExampleModel_SetData demonstrates replacing all data in a LIST model.
+func ExampleModel_SetData() {
+ m, _ := model.New(model.LIST, []any{1, 2, 3})
+ m.SetData([]any{10, 20})
+ fmt.Println(m.Len())
+ fmt.Println(m)
+
+ // Output:
+ // 2
+ // [10,20]
+}
+
+// ExampleModel_SetData_hash demonstrates replacing all data in a HASH model.
+// The map iteration order is non-deterministic, so Sort is called to ensure
+// a consistent result.
+func ExampleModel_SetData_hash() {
+ m, _ := model.New(model.HASH, nil)
+ m.Set("old", 99)
+ m.SetData(map[string]any{"a": 1, "b": 2})
+ m.Sort(sorter.SortByKey) // normalize order
+ var k, v any
+ for m.Next(&k, &v) {
+ n, _ := v.(stdModel.Value).Int()
+ fmt.Printf("%s=%d\n", k, n)
+ }
+
+ // Output:
+ // a=1
+ // b=2
+}
+
+// ExampleModel_GetData demonstrates retrieving a snapshot of the raw data
+// slice and index maps. Mutations to the returned copies do not affect the
+// model.
+func ExampleModel_GetData() {
+ m, _ := model.New(model.LIST, []any{"a", "b", "c"})
+ data, _, _ := m.GetData()
+ for i, item := range data {
+ s, _ := item.(stdModel.Value).String()
+ fmt.Printf("[%d]=%s\n", i, s)
+ }
+
+ // Output:
+ // [0]=a
+ // [1]=b
+ // [2]=c
+}
+
+// ExampleModel_GetData_hash demonstrates using the index maps returned by
+// GetData to look up keys by position and positions by key.
+func ExampleModel_GetData_hash() {
+ m, _ := model.New(model.HASH, nil)
+ m.Set("x", 10)
+ m.Set("y", 20)
+ _, hashIdx, idxHash := m.GetData()
+ fmt.Println("x at index:", hashIdx["x"])
+ fmt.Println("key at index 1:", idxHash[1])
+
+ // Output:
+ // x at index: 0
+ // key at index 1: y
+}
+
+// ── Lock ──────────────────────────────────────────────────────────────────────
+
+// ExampleModel_Lock demonstrates making a model permanently read-only. There
+// is no Unlock — once locked a model accepts reads but rejects all mutations.
+func ExampleModel_Lock() {
+ m, _ := model.New(model.HASH, nil)
+ m.Set("k", 1)
+ m.Lock()
+
+ err := m.Set("k", 2) // write rejected
+ fmt.Println(err != nil)
+
+ v, _ := m.Get("k") // reads still work
+ n, _ := v.Int()
+ fmt.Println(n)
+
+ // Output:
+ // true
+ // 1
+}
+
+// ── Iteration ─────────────────────────────────────────────────────────────────
+
+// ExampleModel_Next demonstrates forward iteration. When the end of the data
+// is reached, Next resets the cursor to -1 and returns false.
+func ExampleModel_Next() {
+ m, _ := model.New(model.HASH, nil)
+ m.Set("a", 1)
+ m.Set("b", 2)
+ m.Set("c", 3)
+ var k, v any
+ for m.Next(&k, &v) {
+ n, _ := v.(stdModel.Value).Int()
+ fmt.Printf("%s=%d\n", k, n)
+ }
+
+ // Output:
+ // a=1
+ // b=2
+ // c=3
+}
+
+// ExampleModel_Prev demonstrates backward iteration starting from a Seek
+// position. Prev clamps to -1 so that a subsequent Next restarts from the
+// beginning.
+func ExampleModel_Prev() {
+ m, _ := model.New(model.LIST, []any{10, 20, 30})
+ m.Seek(2) // start at the last element
+ var k, v any
+ for m.Prev(&k, &v) {
+ n, _ := v.(stdModel.Value).Int()
+ fmt.Println(n)
+ }
+
+ // Output:
+ // 20
+ // 10
+}
+
+// ExampleModel_Cur demonstrates reading the key and value at the current
+// cursor position without advancing the cursor.
+func ExampleModel_Cur() {
+ m, _ := model.New(model.LIST, []any{10, 20, 30})
+ var k, v any
+ fmt.Println(m.Cur(&k, &v)) // false before any iteration
+ m.Next(&k, &v) // pos → 0
+ m.Next(&k, &v) // pos → 1
+ m.Cur(&k, &v)
+ n, _ := v.(stdModel.Value).Int()
+ fmt.Println(n)
+
+ // Output:
+ // false
+ // 20
+}
+
+// ExampleModel_Seek demonstrates positioning the cursor at a specific index
+// so that Cur returns the element at that position.
+func ExampleModel_Seek() {
+ m, _ := model.New(model.LIST, []any{10, 20, 30})
+ m.Seek(2)
+ var k, v any
+ m.Cur(&k, &v)
+ n, _ := v.(stdModel.Value).Int()
+ fmt.Println(n)
+
+ // Output:
+ // 30
+}
+
+// ExampleModel_Seek_hash demonstrates seeking to a hash key by name.
+func ExampleModel_Seek_hash() {
+ m, _ := model.New(model.HASH, nil)
+ m.Set("a", 1)
+ m.Set("b", 2)
+ m.Seek("b")
+ var k, v any
+ m.Cur(&k, &v)
+ n, _ := v.(stdModel.Value).Int()
+ fmt.Println(k, n)
+
+ // Output:
+ // b 2
+}
+
+// ExampleModel_Reset demonstrates resetting the cursor to before the first
+// element so that the next Next call returns the first element.
+func ExampleModel_Reset() {
+ m, _ := model.New(model.LIST, []any{1, 2, 3})
+ var k, v any
+ m.Next(&k, &v) // pos → 0
+ m.Next(&k, &v) // pos → 1
+ m.Reset()
+ m.Next(&k, &v) // pos → 0 again
+ n, _ := v.(stdModel.Value).Int()
+ fmt.Println(n)
+
+ // Output:
+ // 1
+}
+
+// ── Sort & Reverse ────────────────────────────────────────────────────────────
+
+// ExampleModel_Sort demonstrates sorting a LIST model by value in ascending
+// order. SortByValue (==0) is the default; pass SortAsc to trigger it.
+func ExampleModel_Sort() {
+ m, _ := model.New(model.LIST, nil)
+ m.Push(3)
+ m.Push(1)
+ m.Push(2)
+ m.Sort(sorter.SortAsc)
+ var k, v any
+ for m.Next(&k, &v) {
+ n, _ := v.(stdModel.Value).Int()
+ fmt.Println(n)
+ }
+
+ // Output:
+ // 1
+ // 2
+ // 3
+}
+
+// ExampleModel_Sort_byKey demonstrates sorting a HASH model alphabetically
+// by key.
+func ExampleModel_Sort_byKey() {
+ m, _ := model.New(model.HASH, nil)
+ m.Set("c", 3)
+ m.Set("a", 1)
+ m.Set("b", 2)
+ m.Sort(sorter.SortByKey)
+ var k, v any
+ for m.Next(&k, &v) {
+ n, _ := v.(stdModel.Value).Int()
+ fmt.Printf("%s=%d\n", k, n)
+ }
+
+ // Output:
+ // a=1
+ // b=2
+ // c=3
+}
+
+// ExampleModel_Sort_descending demonstrates sorting a LIST model by value in
+// descending order.
+func ExampleModel_Sort_descending() {
+ m, _ := model.New(model.LIST, nil)
+ m.Push(1)
+ m.Push(3)
+ m.Push(2)
+ m.Sort(sorter.SortDesc)
+ var k, v any
+ for m.Next(&k, &v) {
+ n, _ := v.(stdModel.Value).Int()
+ fmt.Println(n)
+ }
+
+ // Output:
+ // 3
+ // 2
+ // 1
+}
+
+// ExampleModel_Sort_asString demonstrates sorting a LIST model by the string
+// representation of each value (lexicographic order).
+func ExampleModel_Sort_asString() {
+ m, _ := model.New(model.LIST, []any{30, 10, 200})
+ m.Sort(sorter.SortByKey | sorter.SortAsString) // SortByKey+SortAsString sorts list values as strings
+ fmt.Println(m) // "10" < "200" < "30" lexicographically
+
+ // Output:
+ // [10,200,30]
+}
+
+// ExampleModel_Reverse demonstrates reversing the order of elements.
+func ExampleModel_Reverse() {
+ m, _ := model.New(model.LIST, []any{1, 2, 3})
+ m.Reverse()
+ fmt.Println(m)
+
+ // Output:
+ // [3,2,1]
+}
+
+// ── Functional ────────────────────────────────────────────────────────────────
+
+// ExampleModel_Filter demonstrates filtering a LIST model, returning a new
+// model containing only the elements for which the callback returns true. The
+// original model is not modified.
+func ExampleModel_Filter() {
+ m, _ := model.New(model.LIST, []any{1, 2, 3, 4, 5})
+ evens := m.Filter(func(v stdModel.Value) bool {
+ n, _ := v.Int()
+ return n%2 == 0
+ })
+ fmt.Println(evens)
+ fmt.Println(m.Len()) // original unchanged
+
+ // Output:
+ // [2,4]
+ // 5
+}
+
+// ExampleModel_Filter_hash demonstrates filtering a HASH model. Matching
+// key→value pairs are preserved in the result.
+func ExampleModel_Filter_hash() {
+ m, _ := model.New(model.HASH, nil)
+ m.Set("a", 1)
+ m.Set("b", 20)
+ m.Set("c", 3)
+ m.Set("d", 40)
+ big := m.Filter(func(v stdModel.Value) bool {
+ n, _ := v.Int()
+ return n > 10
+ })
+ fmt.Println(big)
+
+ // Output:
+ // {"b":20,"d":40}
+}
+
+// ExampleModel_Map demonstrates transforming every element of a LIST model
+// with a callback, returning a new model with the mapped values.
+func ExampleModel_Map() {
+ m, _ := model.New(model.LIST, []any{1, 2, 3})
+ doubled := m.Map(func(v stdModel.Value) stdModel.Value {
+ n, _ := v.Int()
+ tmp, _ := model.New(model.LIST, nil)
+ tmp.Push(n * 2)
+ r, _ := tmp.Get(0)
+ return r
+ })
+ fmt.Println(doubled)
+
+ // Output:
+ // [2,4,6]
+}
+
+// ExampleModel_Reduce demonstrates reducing a LIST model to a single value
+// using a callback. The first element is used as the initial carry value.
+func ExampleModel_Reduce() {
+ m, _ := model.New(model.LIST, []any{3, 1, 4, 1, 5, 9, 2, 6})
+ maxVal := m.Reduce(func(carry, cur stdModel.Value) stdModel.Value {
+ a, _ := carry.Int()
+ b, _ := cur.Int()
+ if b > a {
+ return cur
+ }
+ return carry
+ })
+ n, _ := maxVal.Int()
+ fmt.Println(n)
+
+ // Output:
+ // 9
+}
+
+// ── Merge ─────────────────────────────────────────────────────────────────────
+
+// ExampleModel_Merge demonstrates merging one HASH model into another.
+// Keys present in both models are overwritten by the incoming values.
+func ExampleModel_Merge() {
+ base, _ := model.New(model.HASH, nil)
+ base.Set("a", 1)
+ base.Set("b", 2)
+
+ incoming, _ := model.New(model.HASH, nil)
+ incoming.Set("b", 99) // overwrites
+ incoming.Set("c", 3) // new key
+
+ base.Merge(incoming)
+ fmt.Println(base)
+
+ // Output:
+ // {"a":1,"b":99,"c":3}
+}
+
+// ExampleModel_Merge_list demonstrates merging one LIST model into another.
+// Incoming elements are appended to the base.
+func ExampleModel_Merge_list() {
+ a, _ := model.New(model.LIST, []any{1, 2})
+ b, _ := model.New(model.LIST, []any{3, 4})
+ a.Merge(b)
+ fmt.Println(a)
+
+ // Output:
+ // [1,2,3,4]
+}
+
+// ExampleModel_Merge_nested demonstrates that when both models share a key
+// whose value is itself a Model, Merge recurses into the sub-models rather
+// than replacing one with the other. Non-overlapping keys on both sides are
+// preserved.
+func ExampleModel_Merge_nested() {
+ inner1, _ := model.New(model.HASH, nil)
+ inner1.Set("x", 1)
+ base, _ := model.New(model.HASH, nil)
+ base.Set("shared", inner1)
+ base.Set("base_only", "hello")
+
+ inner2, _ := model.New(model.HASH, nil)
+ inner2.Set("y", 2)
+ incoming, _ := model.New(model.HASH, nil)
+ incoming.Set("shared", inner2)
+ incoming.Set("new_key", "world")
+
+ base.Merge(incoming)
+
+ // "shared" now contains both x (from base) and y (from incoming).
+ v, _ := base.Get("shared")
+ shared, _ := v.Model()
+ fmt.Println(shared)
+ fmt.Println(base.Has("base_only"))
+ fmt.Println(base.Has("new_key"))
+
+ // Output:
+ // {"x":1,"y":2}
+ // true
+ // true
+}
+
+// ── JSON ──────────────────────────────────────────────────────────────────────
+
+// ExampleModel_MarshalJSON demonstrates serializing a model to JSON bytes.
+func ExampleModel_MarshalJSON() {
+ m, _ := model.New(model.LIST, []any{1, "two", true})
+ b, _ := m.MarshalJSON()
+ fmt.Println(string(b))
+
+ // Output:
+ // [1,"two",true]
+}
+
+// ExampleModel_UnmarshalJSON demonstrates deserializing JSON into a model.
+// Calling UnmarshalJSON replaces all existing data. JSON numbers decode as
+// float64, so use Float() to retrieve them precisely.
+func ExampleModel_UnmarshalJSON() {
+ m, _ := model.New(model.LIST, nil)
+ m.UnmarshalJSON([]byte(`[10,20,30]`))
+ v, _ := m.Get(1)
+ n, _ := v.Float()
+ fmt.Println(int(n))
+
+ // Output:
+ // 20
+}
+
+// ExampleModel_UnmarshalJSON_replaces demonstrates that UnmarshalJSON replaces
+// all existing data rather than merging.
+func ExampleModel_UnmarshalJSON_replaces() {
+ m, _ := model.New(model.HASH, nil)
+ m.Set("old", 1)
+ m.UnmarshalJSON([]byte(`{"new":2}`))
+ fmt.Println(m.Has("old"))
+ fmt.Println(m.Has("new"))
+
+ // Output:
+ // false
+ // true
+}
+
+// ExampleModel_MarshalModel demonstrates the MarshalModel method, which is an
+// alias for MarshalJSON and satisfies the Marshaler interface.
+func ExampleModel_MarshalModel() {
+ m, _ := model.New(model.HASH, nil)
+ m.Set("count", 3)
+ b, _ := m.MarshalModel()
+ fmt.Println(string(b))
+
+ // Output:
+ // {"count":3}
+}
+
+// ExampleModel_UnmarshalModel demonstrates UnmarshalModel, which skips
+// deserialization when given the JSON null literal.
+func ExampleModel_UnmarshalModel() {
+ m, _ := model.New(model.HASH, nil)
+ m.UnmarshalModel([]byte(`{"x":1,"y":2}`))
+ fmt.Println(m.Has("x"), m.Has("y"))
+
+ // Output:
+ // true true
+}
+
+// ExampleModel_UnmarshalModel_null demonstrates that UnmarshalModel treats the
+// JSON null literal as a no-op, leaving existing data intact.
+func ExampleModel_UnmarshalModel_null() {
+ m, _ := model.New(model.HASH, nil)
+ m.Set("existing", 1)
+ m.UnmarshalModel([]byte("null"))
+ fmt.Println(m.Has("existing"))
+
+ // Output:
+ // true
+}
+
+// ExampleModel_String demonstrates the String method, which returns the JSON
+// representation of the model and satisfies fmt.Stringer.
+func ExampleModel_String() {
+ m, _ := model.New(model.LIST, []any{1, 2, 3})
+ fmt.Println(m)
+
+ // Output:
+ // [1,2,3]
+}
+
+// ── Value accessors ───────────────────────────────────────────────────────────
+
+// ExampleValue_Bool demonstrates converting a Value to bool.
+func ExampleValue_Bool() {
+ m, _ := model.New(model.LIST, nil)
+ m.Push(true)
+ v, _ := m.Get(0)
+ b, _ := v.Bool()
+ fmt.Println(b)
+
+ // Output:
+ // true
+}
+
+// ExampleValue_Float demonstrates converting a Value to float64.
+func ExampleValue_Float() {
+ m, _ := model.New(model.LIST, nil)
+ m.Push(2.5)
+ v, _ := m.Get(0)
+ f, _ := v.Float()
+ fmt.Println(f)
+
+ // Output:
+ // 2.5
+}
+
+// ExampleValue_Float32 demonstrates converting a Value to float32.
+func ExampleValue_Float32() {
+ m, _ := model.New(model.LIST, nil)
+ m.Push(float32(1.5))
+ v, _ := m.Get(0)
+ f, _ := v.Float32()
+ fmt.Println(f)
+
+ // Output:
+ // 1.5
+}
+
+// ExampleValue_Float64 demonstrates converting a Value to float64 explicitly.
+func ExampleValue_Float64() {
+ m, _ := model.New(model.LIST, nil)
+ m.Push(3.14)
+ v, _ := m.Get(0)
+ f, _ := v.Float64()
+ fmt.Printf("%.2f\n", f)
+
+ // Output:
+ // 3.14
+}
+
+// ExampleValue_Int demonstrates converting a Value to int.
+func ExampleValue_Int() {
+ m, _ := model.New(model.LIST, nil)
+ m.Push(42)
+ v, _ := m.Get(0)
+ n, _ := v.Int()
+ fmt.Println(n)
+
+ // Output:
+ // 42
+}
+
+// ExampleValue_String demonstrates converting a Value to its string
+// representation. Non-string types are cast via the cast package.
+func ExampleValue_String() {
+ m, _ := model.New(model.LIST, nil)
+ m.Push(42)
+ v, _ := m.Get(0)
+ s, _ := v.String()
+ fmt.Println(s)
+
+ // Output:
+ // 42
+}
+
+// ExampleValue_Model demonstrates extracting a nested Model stored as a Value.
+func ExampleValue_Model() {
+ inner, _ := model.New(model.HASH, nil)
+ inner.Set("x", 1)
+
+ outer, _ := model.New(model.LIST, nil)
+ outer.Push(inner)
+
+ v, _ := outer.Get(0)
+ m, _ := v.Model()
+ fmt.Println(m)
+
+ // Output:
+ // {"x":1}
+}
+
+// ExampleValue_Value demonstrates retrieving the raw untyped value stored
+// inside a Value node.
+func ExampleValue_Value() {
+ m, _ := model.New(model.LIST, nil)
+ m.Push("hello")
+ v, _ := m.Get(0)
+ fmt.Println(v.Value())
+
+ // Output:
+ // hello
+}
+
+// ExampleValue_List demonstrates the List accessor, which succeeds only when
+// the underlying data is exactly a []stdModel.Value. The idiomatic way to
+// store a nested list is to use a LIST model; List() is for the uncommon case
+// where a raw []stdModel.Value slice was stored directly.
+func ExampleValue_List() {
+ // Build a []stdModel.Value from a model's snapshot.
+ src, _ := model.New(model.LIST, []any{1, 2, 3})
+ rawData, _, _ := src.GetData()
+ vals := make([]stdModel.Value, len(rawData))
+ for i, d := range rawData {
+ vals[i] = d.(stdModel.Value)
+ }
+
+ // Store the slice and retrieve it via List().
+ m, _ := model.New(model.LIST, nil)
+ m.Push(vals)
+ v, _ := m.Get(0)
+ list, err := v.List()
+ fmt.Println(err)
+ fmt.Println(len(list))
+ for i, item := range list {
+ n, _ := item.Int()
+ fmt.Printf("[%d]=%d\n", i, n)
+ }
+
+ // Output:
+ //
+ // 3
+ // [0]=1
+ // [1]=2
+ // [2]=3
+}
+
+// ExampleValue_Map demonstrates the Map accessor, which succeeds only when the
+// underlying data is exactly a map[string]stdModel.Value. The idiomatic way to
+// store a nested map is to use a HASH model; Map() is for the uncommon case
+// where a raw map was stored directly.
+func ExampleValue_Map() {
+ // Build a map[string]stdModel.Value from a model's snapshot.
+ src, _ := model.New(model.HASH, nil)
+ src.Set("a", 10)
+ src.Set("b", 20)
+ rawData, hashIdx, _ := src.GetData()
+ vals := make(map[string]stdModel.Value, len(hashIdx))
+ for key, idx := range hashIdx {
+ vals[key] = rawData[idx].(stdModel.Value)
+ }
+
+ // Store the map and retrieve it via Map().
+ m, _ := model.New(model.LIST, nil)
+ m.Push(vals)
+ v, _ := m.Get(0)
+ mp, err := v.Map()
+ fmt.Println(err)
+ fmt.Println(len(mp))
+ na, _ := mp["a"].Int()
+ nb, _ := mp["b"].Int()
+ fmt.Println(na)
+ fmt.Println(nb)
+
+ // Output:
+ //
+ // 2
+ // 10
+ // 20
+}
+
+// ── Conversion helpers ────────────────────────────────────────────────────────
+
+// ExampleTo demonstrates the To generic helper for type conversions using the
+// cast package. Returns the zero value on conversion failure.
+func ExampleTo() {
+ fmt.Println(model.To[int]("42"))
+ fmt.Println(model.To[string](100))
+ fmt.Println(model.To[bool](1))
+
+ // Output:
+ // 42
+ // 100
+ // true
+}
+
+// ExampleToE demonstrates the ToE generic helper, which returns the converted
+// value and an error instead of a zero value on failure.
+func ExampleToE() {
+ n, err := model.ToE[int]("42")
+ fmt.Println(n, err)
+ _, err = model.ToE[int]("not-a-number")
+ fmt.Println(err != nil)
+
+ // Output:
+ // 42
+ // true
+}
+
+// ── Error sentinels ───────────────────────────────────────────────────────────
+
+// ExampleInvalidIndex demonstrates the sentinel returned when Get, Delete, or
+// Seek is called with a key or index that does not exist in the model.
+func ExampleInvalidIndex() {
+ list, _ := model.New(model.LIST, []any{1, 2, 3})
+ _, err := list.Get(99)
+ fmt.Println(errors.Is(err, model.InvalidIndex))
+
+ hash, _ := model.New(model.HASH, nil)
+ _, err = hash.Get("missing")
+ fmt.Println(errors.Is(err, model.InvalidIndex))
+
+ // Output:
+ // true
+ // true
+}
+
+// ExampleInvalidIndexType demonstrates the sentinel returned when a LIST model
+// is accessed with a non-integer key type.
+func ExampleInvalidIndexType() {
+ m, _ := model.New(model.LIST, []any{1, 2, 3})
+
+ _, err := m.Get("not-an-int")
+ fmt.Println(errors.Is(err, model.InvalidIndexType))
+
+ err = m.Delete("also-not-an-int")
+ fmt.Println(errors.Is(err, model.InvalidIndexType))
+
+ // Output:
+ // true
+ // true
+}
+
+// ExampleInvalidMethodContext demonstrates the sentinel returned when a method
+// is invoked in an unsupported context, such as calling Push on a HASH model.
+func ExampleInvalidMethodContext() {
+ h, _ := model.New(model.HASH, nil)
+ err := h.Push("value")
+ fmt.Println(errors.Is(err, model.InvalidMethodContext))
+
+ // Merging a model into itself also returns this sentinel.
+ m, _ := model.New(model.HASH, nil)
+ err = m.Merge(m)
+ fmt.Println(errors.Is(err, model.InvalidMethodContext))
+
+ // Output:
+ // true
+ // true
+}
+
+// ExampleReadOnlyModel demonstrates the sentinel returned when a mutation is
+// attempted on a locked model, or when SetType is called on a non-empty model.
+func ExampleReadOnlyModel() {
+ m, _ := model.New(model.HASH, nil)
+ m.Set("k", 1)
+ m.Lock()
+
+ err := m.Set("k", 2)
+ fmt.Println(errors.Is(err, model.ReadOnlyModel))
+
+ // SetType also returns ReadOnlyModel when the model already holds data.
+ m2, _ := model.New(model.HASH, nil)
+ m2.Set("k", 1)
+ err = m2.SetType(model.LIST)
+ fmt.Println(errors.Is(err, model.ReadOnlyModel))
+
+ // Output:
+ // true
+ // true
+}
+
+// ExampleInvalidDataSet demonstrates the sentinel returned when SetData is
+// called with a value whose type does not match the model type.
+func ExampleInvalidDataSet() {
+ list, _ := model.New(model.LIST, nil)
+ err := list.SetData(map[string]any{"key": "val"}) // map not valid for LIST
+ fmt.Println(errors.Is(err, model.InvalidDataSet))
+
+ hash, _ := model.New(model.HASH, nil)
+ err = hash.SetData([]any{1, 2, 3}) // slice not valid for HASH
+ fmt.Println(errors.Is(err, model.InvalidDataSet))
+
+ // Output:
+ // true
+ // true
+}
+
+// ExampleInvalidSortFlagCombination demonstrates the sentinel returned when
+// mutually exclusive sort flags are combined.
+func ExampleInvalidSortFlagCombination() {
+ m, _ := model.New(model.LIST, []any{1, 2, 3})
+ err := m.Sort(sorter.SortAsc | sorter.SortDesc)
+ fmt.Println(errors.Is(err, model.InvalidSortFlagCombination))
+
+ // Output:
+ // true
+}
+
+// ── Comprehensive walkthrough ─────────────────────────────────────────────────
+
+// ExampleModel_complete demonstrates the full model lifecycle: building from
+// JSON, iterating, sorting, filtering, reducing, and marshaling back to JSON.
+func ExampleModel_complete() {
+ // Build a hash model from JSON
+ mdl, _ := model.New(model.HASH, nil)
+ json.Unmarshal([]byte(`{
+ "users": [
+ {"name": "Charlie", "age": 25},
+ {"name": "Alice", "age": 30},
+ {"name": "Bob", "age": 22}
+ ]
+ }`), &mdl)
+ fmt.Println("Model:", mdl)
+
+ // Get the nested list
+ val, _ := mdl.Get("users")
+ users, _ := val.Model()
+
+ // SortByValue (==0) is a no-op: model values are compared by ID then by
+ // element count, and all users have the same (empty) ID and the same number
+ // of fields, so the sort leaves the original insertion order intact.
+ users.(sorter.Sorter).Sort(sorter.SortByValue)
+ fmt.Println("Users:", users)
+
+ // Iterate and print
+ var key, v any
+ for users.(iterator.Iterator).Next(&key, &v) {
+ user, _ := v.(stdModel.Value).Model()
+ nameVal, _ := user.Get("name")
+ name, _ := nameVal.String()
+ ageVal, _ := user.Get("age")
+ age, _ := ageVal.Float() // JSON numbers decode as float64
+ fmt.Printf("%s: %d\n", name, int(age))
+ }
+
+ // Filter to adults only
+ adults := users.Filter(func(v stdModel.Value) bool {
+ user, err := v.Model()
+ if err != nil {
+ return false
+ }
+ ageVal, _ := user.Get("age")
+ age, _ := ageVal.Float()
+ return age >= 25
+ })
+ fmt.Println("Adults:", adults)
+
+ // Reduce to find the oldest adult.
+ oldestVal := adults.Reduce(func(carry, cur stdModel.Value) stdModel.Value {
+ if nil == carry {
+ return cur
+ }
+ oldest, err := carry.Model()
+ if err != nil {
+ return cur
+ }
+ curUser, err := cur.Model()
+ if err != nil {
+ return carry
+ }
+ oldVal, _ := oldest.Get("age")
+ oldestAge, _ := oldVal.Int()
+ curVal, _ := curUser.Get("age")
+ curAge, _ := curVal.Int()
+ if curAge > oldestAge {
+ return cur
+ }
+ return carry
+ })
+ oldest, _ := oldestVal.Model()
+ fmt.Println("Oldest:", oldest)
+
+ // Marshal back to JSON
+ b, _ := json.Marshal(mdl)
+ fmt.Println(string(b))
+
+ // Output: Model: {"users":[{"age":25,"name":"Charlie"},{"age":30,"name":"Alice"},{"age":22,"name":"Bob"}]}
+ // Users: [{"age":25,"name":"Charlie"},{"age":30,"name":"Alice"},{"age":22,"name":"Bob"}]
+ // Charlie: 25
+ // Alice: 30
+ // Bob: 22
+ // Adults: [{"age":25,"name":"Charlie"},{"age":30,"name":"Alice"}]
+ // Oldest: {"age":30,"name":"Alice"}
+ // {"users":[{"age":25,"name":"Charlie"},{"age":30,"name":"Alice"},{"age":22,"name":"Bob"}]}
+}
diff --git a/go.mod b/go.mod
index 68f18c8..0a350cb 100644
--- a/go.mod
+++ b/go.mod
@@ -6,7 +6,7 @@ require (
github.com/bdlm/cast/v2 v2.1.4
github.com/bdlm/errors/v2 v2.1.2
github.com/bdlm/log/v2 v2.0.7
- github.com/bdlm/std/v2 v2.1.0
+ github.com/bdlm/std/v2 v2.1.1-rc3
)
require (
diff --git a/model.go b/model.go
index b671d80..603a5fb 100644
--- a/model.go
+++ b/model.go
@@ -2,71 +2,206 @@ package model
import (
"sync"
+ "sync/atomic"
"github.com/bdlm/cast/v2"
"github.com/bdlm/errors/v2"
stdModel "github.com/bdlm/std/v2/model"
)
-// modelType is a data type for defining Model types.
-type modelType int
-
const (
- // Dict defines a dictionary model type.
- Dict modelType = iota
- // List defines a list model type.
- List
+ // HASH is the model type for a string-keyed ordered map. All keys are
+ // normalized to string via bdlm/cast: Set, Get, Has, and Delete accept any
+ // type and cast it to string before use. When a HASH model is created via
+ // [New] with initial data or populated via [Model.UnmarshalJSON], keys are
+ // sorted alphabetically. When populated via sequential [Model.Set] calls,
+ // keys are stored in insertion order; call [Model.Sort] to normalize.
+ //
+ // Re-exported from github.com/bdlm/std/v2/model for caller convenience.
+ HASH = stdModel.HASH
+
+ // LIST is the model type for an integer-indexed array. Keys must be one of
+ // the standard integer types (int, int8, int16, int32, int64, uint, uint8,
+ // uint16, uint32, uint64). Indices are zero-based and contiguous; deleting
+ // an element shifts all higher-indexed elements left. New elements are
+ // added via [Model.Push]; [Model.Set] updates existing positions only.
+ //
+ // Re-exported from github.com/bdlm/std/v2/model for caller convenience.
+ LIST = stdModel.LIST
)
-// Model defines the model data structure.
+// Model is the core data container. It holds an ordered collection of values
+// in either HASH (string-keyed map) or LIST (integer-indexed array) mode.
+//
+// Internally, all elements are stored in a single contiguous slice (data).
+// For HASH models two companion maps maintain the bidirectional key↔position
+// relationship: hashIdx maps each string key to its position in data, and
+// idxHash maps each position back to its key. This layout gives O(1) reads
+// and stable insertion order independently of Go's non-deterministic map
+// iteration.
+//
+// A sync.RWMutex guards all mutable fields. Read operations acquire a shared
+// lock; write operations acquire an exclusive lock. The locked flag and model
+// type are stored as atomic values so that [Model.Lock] and [Model.GetType]
+// are always safe to call without holding the mutex.
+//
+// Model satisfies the following interfaces from github.com/bdlm/std/v2:
+// - model.Model — the full data-container interface
+// - iterator.Iterator — bidirectional cursor iteration
+// - sorter.Sorter — in-place sorting and reversal
+// - fmt.Stringer — JSON-formatted string representation
type Model struct {
- id any // model identifier
- locked bool // model read-only flag
- typ stdModel.ModelType // model type, either stdModel.ModelTypeHash or stdModel.ModelTypeList
-
- mux *sync.Mutex // goroutine-safe
- data []any // data store
- hashIdx map[string]int // stdModel.ModelTypeHash data index
- idxHash map[int]string // stdModel.ModelTypeHash hash index
- pos int // current stdModel.Iterator cursor position
+ // id is the model's optional identifier, set by SetID and read by GetID.
+ // It is not interpreted by the model; it serves as the primary sort key
+ // when comparing nested models during value-based sorting.
+ id any
+
+ // locked is the read-only flag. Once set by Lock it is never cleared.
+ // Stored atomically so that checkLocked can be called without the mutex.
+ locked atomic.Bool
+
+ // typ stores the model type (HASH or LIST) as an int64. Using an atomic
+ // allows GetType to be called safely from within callbacks that already
+ // hold the mutex.
+ typ atomic.Int64
+
+ // mux protects data, hashIdx, idxHash, pos, and id. Read operations
+ // acquire RLock; write operations acquire Lock.
+ mux *sync.RWMutex
+
+ // data is the element store in model order (index 0 to Len-1). Each
+ // element is a *Value. The slice may contain non-Value entries only
+ // immediately after a raw SetData call; all other write paths go through
+ // toValue, which normalizes entries to *Value.
+ data []any
+
+ // hashIdx maps string key → position in data. Only populated for HASH
+ // models. Must always be consistent with idxHash and data.
+ hashIdx map[string]int
+
+ // idxHash maps position in data → string key. Only populated for HASH
+ // models. Must always be consistent with hashIdx and data.
+ idxHash map[int]string
+
+ // pos is the cursor position used by Next, Prev, Cur, Seek, and Reset.
+ // A value of -1 means "before the first element". Any successful mutation
+ // (Delete, SetData, Sort, Reverse) resets pos to -1.
+ pos int
}
-// New returns a new stdModel.Model.
-func New(modelType stdModel.ModelType, data any) *Model {
+// New creates and returns a new Model of the given modelType.
+//
+// If data is non-nil it is imported immediately:
+// - For HASH models, data must be map[string]any. Nested map values become
+// child HASH models; nested slice values become child LIST models. Keys
+// are sorted alphabetically after all key-value pairs are inserted.
+// - For LIST models, data must be []any. Nested maps and slices are
+// converted to child models in the same way.
+//
+// Any other non-nil data type returns a non-nil error and a nil *Model.
+// Passing nil data creates an empty model ready for use.
+func New(modelType stdModel.ModelType, data any) (*Model, error) {
model := &Model{
- mux: &sync.Mutex{},
- typ: modelType,
+ mux: &sync.RWMutex{},
hashIdx: map[string]int{},
idxHash: map[int]string{},
pos: -1,
}
+ model.typ.Store(int64(modelType))
if data != nil {
if _, err := model.importData(data); err != nil {
- panic(err)
+ return nil, errors.Wrap(err, "failed to import data")
}
}
- return model
+ return model, nil
+}
+
+// toValue returns v wrapped in a *Value. If v is already a non-nil *Value it
+// is returned as-is to avoid double-wrapping. A nil *Value is still wrapped
+// so that callers always receive a valid, non-nil *Value.
+func toValue(v any) *Value {
+ if val, ok := v.(*Value); ok && val != nil {
+ return val
+ }
+ return &Value{v}
+}
+
+// asModel extracts a stdModel.Model from v. If v is a *Value the extraction
+// looks through the wrapper to the underlying data before attempting the type
+// assertion, so both raw stdModel.Model values and *Value-wrapped ones are
+// handled transparently.
+func asModel(v any) (stdModel.Model, bool) {
+ if val, ok := v.(*Value); ok {
+ v = val.data
+ }
+ m, ok := v.(stdModel.Model)
+ return m, ok
+}
+
+// checkLocked returns a [ReadOnlyModel] error if the model's locked flag is
+// set. It must be called both before acquiring the write mutex (fast path for
+// already-locked models) and again after acquiring it (to close the TOCTOU
+// window between the pre-check and the mux.Lock call; another goroutine could
+// call Lock between those two points).
+func (mdl *Model) checkLocked() error {
+ if mdl.locked.Load() {
+ return errors.WrapE(ReadOnlyModel, errors.Errorf("model is locked"))
+ }
+ return nil
}
-// Delete removes a value from this model.
+// Delete removes the element identified by key from the model.
+//
+// For LIST models, key must be one of the integer types (int, int8, int16,
+// int32, int64, uint, uint8, uint16, uint32, uint64); any other type returns
+// [InvalidIndexType]. A valid integer key that is out of range returns
+// [InvalidIndex]. After deletion all elements at higher indices shift left,
+// maintaining contiguous zero-based indexing.
+//
+// For HASH models, key is cast to string via bdlm/cast. If the resulting key
+// does not exist, [InvalidIndex] is returned. After deletion, the positions of
+// all elements that had a higher index than the deleted element are decremented
+// by one; both index maps are updated atomically within the write lock.
+//
+// A successful delete resets the cursor to -1. A failed delete (wrong type or
+// out-of-range key) leaves the cursor unchanged.
+//
+// Returns [ReadOnlyModel] if the model is locked.
func (mdl *Model) Delete(key any) error {
- if mdl.locked {
- return errors.WrapE(ReadOnlyProperty, errors.Errorf("model is locked"))
+ if err := mdl.checkLocked(); err != nil {
+ return err
}
mdl.mux.Lock()
defer mdl.mux.Unlock()
- if stdModel.ModelTypeList == mdl.GetType() {
+ if err := mdl.checkLocked(); err != nil {
+ return err
+ }
+ if LIST == mdl.GetType() {
+ switch key.(type) {
+ case int, int8, int16, int32, int64,
+ uint, uint8, uint16, uint32, uint64:
+ default:
+ return errors.WrapE(InvalidIndexType, errors.Errorf("key '%v' must be an integer", key))
+ }
k := cast.To[int](key)
if k < 0 || k >= len(mdl.data) {
return errors.WrapE(InvalidIndex, errors.Errorf("index '%d' out of range", k))
}
- mdl.data = append(mdl.data[:k], mdl.data[k+1:]...)
+ mdl.pos = -1 // successful mutation invalidates cursor
+ n := len(mdl.data)
+ copy(mdl.data[k:], mdl.data[k+1:])
+ mdl.data[n-1] = nil // zero last slot to allow GC of the moved element
+ mdl.data = mdl.data[:n-1]
return nil
}
k := cast.To[string](key)
if idx, ok := mdl.hashIdx[k]; ok {
- mdl.data = append(mdl.data[:idx], mdl.data[idx+1:]...)
+ mdl.pos = -1 // successful mutation invalidates cursor
+ n := len(mdl.data)
+ copy(mdl.data[idx:], mdl.data[idx+1:])
+ mdl.data[n-1] = nil // zero last slot to allow GC of the moved element
+ mdl.data = mdl.data[:n-1]
delete(mdl.hashIdx, k)
delete(mdl.idxHash, idx)
for i := idx; i < len(mdl.data); i++ {
@@ -80,64 +215,94 @@ func (mdl *Model) Delete(key any) error {
return errors.WrapE(InvalidIndex, errors.Errorf("index '%s' out of range", k))
}
-// Filter filters elements of the data using a callback function and returns
-// the result.
+// Filter returns a new model of the same type containing only the elements for
+// which callback returns true. The original model is not modified.
+//
+// Filter operates on a point-in-time snapshot obtained via [Model.GetData]
+// before the first callback invocation. The callback is called without holding
+// the model mutex, so it is safe for the callback to read or write the model
+// being filtered without deadlocking.
+//
+// For HASH models the result preserves the keys and their snapshot order. For
+// LIST models elements are appended to the result in snapshot order.
func (mdl *Model) Filter(callback func(stdModel.Value) bool) stdModel.Model {
- result := New(mdl.GetType(), nil)
- mdl.mux.Lock()
- defer mdl.mux.Unlock()
- for i := 0; i < len(mdl.data); i++ {
- v := toValue(mdl.data[i])
- if callback(v) {
- if stdModel.ModelTypeHash == mdl.GetType() {
- result.Set(mdl.idxHash[i], v)
+ modelType := mdl.GetType()
+ data, _, idxHash := mdl.GetData()
+ result, _ := New(modelType, nil)
+ for i, v := range data {
+ val := toValue(v)
+ if callback(val) {
+ if HASH == modelType {
+ result.Set(idxHash[i], val)
} else {
- result.Push(v)
+ result.Push(val)
}
}
}
return result
}
-// Get returns the specified data value in this model.
+// Get returns the value stored at key.
+//
+// For HASH models, key is cast to string via bdlm/cast. If the resulting key
+// does not exist, [InvalidIndex] is returned.
+//
+// For LIST models, key must be one of the integer types (int, int8, int16,
+// int32, int64, uint, uint8, uint16, uint32, uint64); any other type returns
+// [InvalidIndexType]. An integer key that is negative or >= [Model.Len]
+// returns [InvalidIndex].
+//
+// On success the returned value is always a non-nil *[Value].
func (mdl *Model) Get(key any) (stdModel.Value, error) {
- if stdModel.ModelTypeHash == mdl.GetType() {
+ if HASH == mdl.GetType() {
var ok bool
var idx int
// hash keys are always strings
hashIdx := cast.To[string](key)
- mdl.mux.Lock()
- defer mdl.mux.Unlock()
+ mdl.mux.RLock()
+ defer mdl.mux.RUnlock()
if idx, ok = mdl.hashIdx[hashIdx]; !ok {
return nil, errors.WrapE(InvalidIndex, errors.Errorf("invalid index '%s'", hashIdx))
}
- ret := mdl.data[idx]
- return &Value{ret}, nil
+ return toValue(mdl.data[idx]), nil
}
// List model
switch key.(type) {
- case int, int8, int16, int32, int64:
- mdl.mux.Lock()
- defer mdl.mux.Unlock()
+ case int, int8, int16, int32, int64,
+ uint, uint8, uint16, uint32, uint64:
+ mdl.mux.RLock()
+ defer mdl.mux.RUnlock()
k := cast.To[int](key)
if k < 0 || k >= len(mdl.data) {
return nil, errors.WrapE(InvalidIndex, errors.Errorf("invalid index '%d'", k))
}
- return &Value{mdl.data[k]}, nil
+ return toValue(mdl.data[k]), nil
default:
return nil, errors.WrapE(InvalidIndexType, errors.Errorf("key '%v' must be an integer", key))
}
}
-// GetData returns the current data set and indexes.
+// GetData returns isolated copies of the model's internal data slice and both
+// index maps. Callers may read or modify the returned values without affecting
+// the model's internal state.
+//
+// The first return value is a shallow copy of the data slice in current model
+// order (insertion order, or whatever order Sort or Reverse last established).
+// The second is a copy of hashIdx (key → position) and the third is a copy of
+// idxHash (position → key). For LIST models, both maps are empty but non-nil.
+//
+// GetData is the recommended way to snapshot state before iterating with an
+// external loop or before passing data to a concurrent worker. It is also used
+// internally by [Model.Filter], [Model.Map], and [Model.Reduce] to take a
+// snapshot before invoking user callbacks.
func (mdl *Model) GetData() ([]any, map[string]int, map[int]string) {
- mdl.mux.Lock()
- defer mdl.mux.Unlock()
+ mdl.mux.RLock()
+ defer mdl.mux.RUnlock()
data := make([]any, len(mdl.data))
copy(data, mdl.data)
hashIdx := make(map[string]int, len(mdl.hashIdx))
@@ -151,19 +316,35 @@ func (mdl *Model) GetData() ([]any, map[string]int, map[int]string) {
return data, hashIdx, idxHash
}
-// GetID returns this model's id.
+// GetID returns the model's identifier as set by [Model.SetID]. Returns nil if
+// SetID has never been called. The identifier is not interpreted by the model;
+// it is stored and returned as-is. It is used as the primary sort key when
+// comparing nested models during value-based sorting (see [Model.Sort]).
func (mdl *Model) GetID() any {
+ mdl.mux.RLock()
+ defer mdl.mux.RUnlock()
return mdl.id
}
-// GetType returns the model type.
+// GetType returns the model type, either [HASH] or [LIST]. The type is read
+// atomically and is safe to call from any goroutine, including from within a
+// callback or a method that already holds the model mutex.
func (mdl *Model) GetType() stdModel.ModelType {
- return mdl.typ
+ return stdModel.ModelType(mdl.typ.Load())
}
-// Has tests to see if a specified data element exists in this model.
+// Has reports whether an element identified by key exists in the model.
+//
+// For LIST models, key must be one of the integer types (int, int8, int16,
+// int32, int64, uint, uint8, uint16, uint32, uint64); any other type returns
+// false. A valid integer key that is negative or >= [Model.Len] returns false.
+//
+// For HASH models, key is cast to string via bdlm/cast and compared against
+// the key index.
func (mdl *Model) Has(key any) bool {
- if stdModel.ModelTypeList == mdl.GetType() {
+ mdl.mux.RLock()
+ defer mdl.mux.RUnlock()
+ if LIST == mdl.GetType() {
switch key.(type) {
case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64:
k := cast.To[int](key)
@@ -171,29 +352,45 @@ func (mdl *Model) Has(key any) bool {
return true
}
}
- } else if kstr, ok := key.(string); ok {
- if _, ok := mdl.hashIdx[kstr]; ok {
+ } else {
+ if _, ok := mdl.hashIdx[cast.To[string](key)]; ok {
return true
}
}
return false
}
-// Lock marks this model as read-only.
+// Lock marks the model permanently read-only. Once called, all write
+// operations (Set, Push, Delete, Merge, Reverse, Sort, SetData, SetID,
+// SetType, UnmarshalJSON) return [ReadOnlyModel] and leave the model
+// unchanged. There is no corresponding Unlock; the decision is irreversible.
+//
+// Lock stores the locked flag atomically, so any goroutine that subsequently
+// reads the flag — whether under the write mutex or not — observes the locked
+// state immediately.
func (mdl *Model) Lock() {
- mdl.locked = true
+ mdl.locked.Store(true)
}
-// Map applies a callback to all elements in this model and returns the result.
+// Map returns a new model of the same type with each element replaced by the
+// value returned by callback. The original model is not modified.
+//
+// Map operates on a point-in-time snapshot obtained via [Model.GetData] before
+// the first callback invocation. The callback is called without holding the
+// model mutex, so it is safe for the callback to read or write the model being
+// mapped without deadlocking.
+//
+// For HASH models the result preserves the original keys and their snapshot
+// order. For LIST models elements appear in snapshot order.
func (mdl *Model) Map(callback func(stdModel.Value) stdModel.Value) stdModel.Model {
- result := New(mdl.GetType(), nil)
- mdl.mux.Lock()
- defer mdl.mux.Unlock()
- for i := 0; i < len(mdl.data); i++ {
- v := toValue(mdl.data[i])
- mapped := callback(v)
- if stdModel.ModelTypeHash == mdl.GetType() {
- result.Set(mdl.idxHash[i], mapped)
+ modelType := mdl.GetType()
+ data, _, idxHash := mdl.GetData()
+ result, _ := New(modelType, nil)
+ for i, v := range data {
+ val := toValue(v)
+ mapped := callback(val)
+ if HASH == modelType {
+ result.Set(idxHash[i], mapped)
} else {
result.Push(mapped)
}
@@ -201,62 +398,106 @@ func (mdl *Model) Map(callback func(stdModel.Value) stdModel.Value) stdModel.Mod
return result
}
-// Merge merges data from any Model into this Model.
+// Merge merges all values from incoming into the receiver model. The merge
+// strategy depends on the combination of model types:
+//
+// - HASH ← HASH: For each key in incoming, if the key exists in both models
+// and both values are themselves models, the sub-models are merged
+// recursively. Otherwise the incoming value overwrites the receiver's value
+// for that key. Keys present only in the receiver are preserved; keys
+// present only in incoming are added.
+//
+// - HASH ← LIST: Each list element's integer index is cast to string and
+// used as a hash key. Existing hash keys with matching string-cast indices
+// are overwritten.
+//
+// - LIST ← LIST: Incoming elements are appended to the receiver in order.
+//
+// - LIST ← HASH: Hash values are appended to the receiver in their current
+// insertion order; keys are ignored.
+//
+// Merge returns [InvalidMethodContext] if incoming is the same pointer as the
+// receiver (self-merge). It returns [ReadOnlyModel] if the receiver is locked.
+//
+// During recursive sub-model merges the receiver's write lock is released and
+// reacquired. Within that window a concurrent Lock call can take effect; the
+// lock state is rechecked after reacquisition and the merge is aborted if the
+// receiver has been locked.
func (mdl *Model) Merge(incoming stdModel.Model) error {
- if mdl.locked {
- return errors.WrapE(ReadOnlyProperty, errors.Errorf("model is locked"))
+ if err := mdl.checkLocked(); err != nil {
+ return err
+ }
+ if m, ok := incoming.(*Model); ok && m == mdl {
+ return errors.WrapE(InvalidMethodContext, errors.Errorf("cannot merge a model into itself"))
}
inData, inHashIdx, _ := incoming.GetData()
+ held := true
+ mdl.mux.Lock()
+ defer func() {
+ if held {
+ mdl.mux.Unlock()
+ }
+ }()
+ if err := mdl.checkLocked(); err != nil { // double-check inside lock
+ return err
+ }
+
switch mdl.GetType() {
- case stdModel.ModelTypeHash:
- if incoming.GetType() == stdModel.ModelTypeHash {
+ case HASH:
+ if incoming.GetType() == HASH {
// hash into hash: incoming wins; merge nested models recursively
for key, inIdx := range inHashIdx {
inVal := inData[inIdx]
- mdl.mux.Lock()
myIdx, exists := mdl.hashIdx[key]
var myVal any
if exists {
myVal = mdl.data[myIdx]
}
- mdl.mux.Unlock()
if exists {
myModel, myIsModel := asModel(myVal)
inModel, inIsModel := asModel(inVal)
if myIsModel && inIsModel {
- if err := myModel.Merge(inModel); err != nil {
+ held = false
+ mdl.mux.Unlock()
+ err := myModel.Merge(inModel)
+ mdl.mux.Lock()
+ held = true
+ if err2 := mdl.checkLocked(); err2 != nil {
+ return err2
+ }
+ if err != nil {
return err
}
continue
}
}
- if err := mdl.Set(key, inVal); err != nil {
+ if err := mdl.set(key, inVal); err != nil {
return err
}
}
} else {
// list into hash: string-cast indices become keys
for i, v := range inData {
- if err := mdl.Set(cast.To[string](i), v); err != nil {
+ if err := mdl.set(cast.To[string](i), v); err != nil {
return err
}
}
}
- case stdModel.ModelTypeList:
- if incoming.GetType() == stdModel.ModelTypeList {
+ case LIST:
+ if incoming.GetType() == LIST {
// list into list: append
for _, v := range inData {
- if err := mdl.Push(v); err != nil {
+ if err := mdl.push(v); err != nil {
return err
}
}
} else {
// hash into list: append values in insertion order, ignore keys
for i := 0; i < len(inData); i++ {
- if err := mdl.Push(inData[i]); err != nil {
+ if err := mdl.push(inData[i]); err != nil {
return err
}
}
@@ -266,55 +507,90 @@ func (mdl *Model) Merge(incoming stdModel.Model) error {
return nil
}
-// Push a value to the end of the internal data store.
+// Push appends value to the end of a LIST model and returns nil on success.
+// Returns [InvalidMethodContext] for HASH models. Returns [ReadOnlyModel] if
+// the model is locked.
func (mdl *Model) Push(value any) error {
- if mdl.locked {
- return errors.WrapE(ReadOnlyProperty, errors.Errorf("model is locked"))
+ if err := mdl.checkLocked(); err != nil {
+ return err
}
- // stdModel.ModelTypeList only
- if stdModel.ModelTypeList != mdl.GetType() {
- return errors.WrapE(InvalidMethodContext, errors.Errorf("Push() is only valid for stdModel.ModelTypeList model types"))
+ mdl.mux.Lock()
+ defer mdl.mux.Unlock()
+ if err := mdl.checkLocked(); err != nil {
+ return err
}
+ return mdl.push(value)
+}
- mdl.mux.Lock()
+// push is the write-lock-held implementation of [Model.Push]. Callers must
+// hold mdl.mux exclusively. Returns [InvalidMethodContext] if the model type
+// is not LIST; does not check the locked flag (callers are responsible for
+// that check before acquiring the lock).
+func (mdl *Model) push(value any) error {
+ if LIST != mdl.GetType() {
+ return errors.WrapE(InvalidMethodContext, errors.Errorf("Push() is only valid for LIST model types"))
+ }
mdl.data = append(mdl.data, toValue(value))
- mdl.mux.Unlock()
return nil
}
-// Reduce iteratively reduces the data to a single value using a callback
-// function and returns the result.
+// Reduce iteratively reduces the model to a single value by applying callback
+// to each element in sequence. The first element serves as the initial carry
+// value; the callback is first invoked with that carry and the second element.
+// The return value of each invocation becomes the carry for the next. After
+// all elements are processed the final carry is returned.
+//
+// For an empty model Reduce returns nil without invoking the callback.
//
-// The callback takes two stdModel.Value arguments: carry (the result of the
-// previous iteration, or the first element on the first iteration) and cur
-// (the current element). The callback returns a stdModel.Value which becomes
-// the carry for the next iteration. After all iterations, Reduce returns the
-// final carry value. Returns nil for an empty model.
+// Reduce operates on a point-in-time snapshot obtained via [Model.GetData]
+// before the first callback invocation. The callback is called without holding
+// the model mutex, so it is safe for the callback to read or write the model.
func (mdl *Model) Reduce(callback func(carry, cur stdModel.Value) stdModel.Value) stdModel.Value {
- mdl.mux.Lock()
- defer mdl.mux.Unlock()
- if len(mdl.data) == 0 {
+ data, _, _ := mdl.GetData()
+ if len(data) == 0 {
return nil
}
- var carry stdModel.Value = toValue(mdl.data[0])
- for i := 1; i < len(mdl.data); i++ {
- carry = callback(carry, toValue(mdl.data[i]))
+ var carry stdModel.Value = toValue(data[0])
+ for i := 1; i < len(data); i++ {
+ carry = callback(carry, toValue(data[i]))
}
return carry
}
-// Set stores a value in the internal data store. All values must be identified
-// by key.
+// Set stores value at key in the model.
+//
+// For HASH models, key is cast to string via bdlm/cast. If the key already
+// exists its value is overwritten in place, preserving the existing insertion
+// order. New keys are appended at the end. Hash keys are not automatically
+// re-sorted after a Set call; call [Model.Sort] if a specific order is needed.
+//
+// For LIST models, key must be one of the integer types (int, int8, int16,
+// int32, int64, uint, uint8, uint16, uint32, uint64); any other type returns
+// [InvalidIndexType]. The index must be in [0, Len); out-of-range indices
+// return [InvalidIndex]. To append new elements to a list, use [Model.Push].
+//
+// Set does not reset the cursor; the cursor position remains valid after a
+// Set call regardless of which element was updated.
+//
+// Returns [ReadOnlyModel] if the model is locked.
func (mdl *Model) Set(key any, value any) error {
- if mdl.locked {
- return errors.WrapE(ReadOnlyProperty, errors.Errorf("model is locked"))
+ if err := mdl.checkLocked(); err != nil {
+ return err
}
- // Hash model
- if stdModel.ModelTypeHash == mdl.GetType() {
- // hash keys are always strings
+ mdl.mux.Lock()
+ defer mdl.mux.Unlock()
+ if err := mdl.checkLocked(); err != nil {
+ return err
+ }
+ return mdl.set(key, value)
+}
+
+// set is the write-lock-held implementation of [Model.Set]. Callers must hold
+// mdl.mux exclusively and must have verified the locked flag before acquiring
+// the lock.
+func (mdl *Model) set(key any, value any) error {
+ if HASH == mdl.GetType() {
idx := cast.To[string](key)
- mdl.mux.Lock()
- defer mdl.mux.Unlock()
if _, ok := mdl.hashIdx[idx]; !ok {
mdl.hashIdx[idx] = len(mdl.data)
mdl.idxHash[len(mdl.data)] = idx
@@ -324,14 +600,10 @@ func (mdl *Model) Set(key any, value any) error {
mdl.data[mdl.hashIdx[idx]] = toValue(value)
return nil
}
-
- // List model
switch key.(type) {
case int, int8, int16, int32, int64,
uint, uint8, uint16, uint32, uint64:
k := cast.To[int](key)
- mdl.mux.Lock()
- defer mdl.mux.Unlock()
if k >= len(mdl.data) || k < 0 {
return errors.WrapE(InvalidIndex, errors.Errorf("invalid index '%d'", k))
}
@@ -342,26 +614,57 @@ func (mdl *Model) Set(key any, value any) error {
}
}
-// SetID sets this Model's identifier property.
+// SetID sets the model's identifier to id. The identifier is stored as-is and
+// returned unchanged by [Model.GetID]. It has no semantic meaning to the model
+// itself, but is used as the primary sort key when comparing nested models
+// during value-based sorting (see [Model.Sort]).
+//
+// Returns [ReadOnlyModel] if the model is locked.
func (mdl *Model) SetID(id any) error {
- if mdl.locked {
- return errors.WrapE(ReadOnlyProperty, errors.Errorf("model is locked"))
+ if err := mdl.checkLocked(); err != nil {
+ return err
+ }
+ mdl.mux.Lock()
+ defer mdl.mux.Unlock()
+ if err := mdl.checkLocked(); err != nil {
+ return err
}
mdl.id = id
return nil
}
-// SetData replaces the current data stored in the model with the provided data.
+// SetData replaces the entire contents of the model with data.
+//
+// For LIST models, data must be []any. The input slice is copied so that
+// subsequent mutations to the original slice do not affect the model.
+//
+// For HASH models, data must be map[string]any. The key-value pairs are
+// inserted into a fresh data store. Because Go map iteration order is
+// non-deterministic, call [Model.Sort] afterward if a specific element order
+// is required. Values are stored without wrapping in *[Value]; they are wrapped
+// transparently on retrieval by [Model.Get] and the iterator methods.
+//
+// A successful SetData resets the cursor to -1. A failed call (wrong type for
+// the model type) leaves the cursor unchanged and returns [InvalidDataSet].
+//
+// Returns [ReadOnlyModel] if the model is locked.
func (mdl *Model) SetData(data any) error {
- if mdl.locked {
- return errors.WrapE(ReadOnlyProperty, errors.Errorf("model is locked"))
+ if err := mdl.checkLocked(); err != nil {
+ return err
+ }
+ mdl.mux.Lock()
+ defer mdl.mux.Unlock()
+ if err := mdl.checkLocked(); err != nil {
+ return err
}
- if stdModel.ModelTypeList == mdl.GetType() {
+ if LIST == mdl.GetType() {
d, ok := data.([]any)
if !ok {
return errors.WrapE(InvalidDataSet, errors.Errorf("invalid data set for list model"))
}
- mdl.data = d
+ mdl.pos = -1 // successful mutation invalidates cursor
+ mdl.data = make([]any, len(d))
+ copy(mdl.data, d)
return nil
}
@@ -370,7 +673,10 @@ func (mdl *Model) SetData(data any) error {
return errors.WrapE(InvalidDataSet, errors.Errorf("invalid data set for hash model"))
}
+ mdl.pos = -1 // successful mutation invalidates cursor
mdl.data = []any{}
+ mdl.hashIdx = map[string]int{}
+ mdl.idxHash = map[int]string{}
for k, v := range d {
mdl.hashIdx[k] = len(mdl.data)
mdl.idxHash[len(mdl.data)] = k
@@ -379,32 +685,34 @@ func (mdl *Model) SetData(data any) error {
return nil
}
-// SetType sets the model type. If any data is stored in this model, this
-// property becomes read-only.
+// SetType changes the model type to typ. It succeeds only when the model is
+// empty; if any data has been stored, SetType returns [ReadOnlyModel] to
+// signal that the type is effectively read-only while data is present.
+//
+// Returns [ReadOnlyModel] if the model is locked or non-empty.
func (mdl *Model) SetType(typ stdModel.ModelType) error {
- if mdl.locked {
- return errors.WrapE(ReadOnlyProperty, errors.Errorf("model is locked"))
+ if err := mdl.checkLocked(); err != nil {
+ return err
+ }
+ mdl.mux.Lock()
+ defer mdl.mux.Unlock()
+ if err := mdl.checkLocked(); err != nil {
+ return err
}
if len(mdl.data) > 0 {
- return errors.WrapE(ReadOnlyProperty, errors.Errorf("model is not empty, type cannot be modified"))
+ return errors.WrapE(ReadOnlyModel, errors.Errorf("model is not empty, type cannot be modified"))
}
- mdl.typ = typ
+ mdl.typ.Store(int64(typ))
return nil
}
-// toValue wraps v in a *Value, or returns it directly if it is already one.
-func toValue(v any) *Value {
- if val, ok := v.(*Value); ok && val != nil {
- return val
+// String implements [fmt.Stringer] by returning the JSON representation of the
+// model. If [Model.MarshalJSON] returns an error, String returns the error
+// message instead of valid JSON.
+func (mdl *Model) String() string {
+ byts, err := mdl.MarshalJSON()
+ if err != nil {
+ return err.Error()
}
- return &Value{v}
-}
-
-// asModel extracts a stdModel.Model from v, unwrapping a *Value if necessary.
-func asModel(v any) (stdModel.Model, bool) {
- if val, ok := v.(*Value); ok {
- v = val.data
- }
- m, ok := v.(stdModel.Model)
- return m, ok
+ return string(byts)
}
diff --git a/model.importer.go b/model.importer.go
index 8e219ed..c9e070d 100644
--- a/model.importer.go
+++ b/model.importer.go
@@ -2,15 +2,29 @@ package model
import (
"github.com/bdlm/errors/v2"
- stdModel "github.com/bdlm/std/v2/model"
stdSorter "github.com/bdlm/std/v2/sorter"
)
+// importMap populates node with the contents of data, a Go map[string]any as
+// produced by encoding/json. Each value is handled as follows:
+//
+// - map[string]any: a new HASH child model is created and recursively
+// populated by importMap, then stored with node.Set.
+// - []any: a new LIST child model is created and populated by importSlice,
+// then stored with node.Set.
+// - any other type: stored as-is via node.Set.
+//
+// After all key-value pairs are inserted, the node is sorted alphabetically
+// by key (Sort(SortByKey)) to ensure deterministic iteration order regardless
+// of Go's non-deterministic map iteration.
+//
+// Callers must not hold node's mutex; importMap acquires it internally through
+// node.Set and node.Sort.
func importMap(data map[string]any, node *Model) (*Model, error) {
for k, v := range data {
switch typedV := v.(type) {
case map[string]any:
- n := New(stdModel.ModelTypeHash, nil)
+ n, _ := New(HASH, nil)
child, err := importMap(typedV, n)
if err != nil {
return nil, errors.Wrap(err, "failed to import nested map at key '%s'", k)
@@ -19,7 +33,7 @@ func importMap(data map[string]any, node *Model) (*Model, error) {
return nil, errors.Wrap(err, "failed to set key '%s'", k)
}
case []any:
- n := New(stdModel.ModelTypeList, nil)
+ n, _ := New(LIST, nil)
child, err := importSlice(typedV, n)
if err != nil {
return nil, errors.Wrap(err, "failed to import nested slice at key '%s'", k)
@@ -39,11 +53,24 @@ func importMap(data map[string]any, node *Model) (*Model, error) {
return node, nil
}
+// importSlice populates node with the contents of data, a Go []any as produced
+// by encoding/json. Each element is handled as follows:
+//
+// - map[string]any: a new HASH child model is created and populated by
+// importMap, then appended via node.Push.
+// - []any: a new LIST child model is created and recursively populated by
+// importSlice, then appended via node.Push.
+// - any other type: appended as-is via node.Push.
+//
+// Insertion order is preserved; no sorting is applied to list models.
+//
+// Callers must not hold node's mutex; importSlice acquires it internally
+// through node.Push.
func importSlice(data []any, node *Model) (*Model, error) {
for i, v := range data {
switch typedV := v.(type) {
case map[string]any:
- n := New(stdModel.ModelTypeHash, nil)
+ n, _ := New(HASH, nil)
child, err := importMap(typedV, n)
if err != nil {
return nil, errors.Wrap(err, "failed to import nested map at index '%d'", i)
@@ -52,7 +79,7 @@ func importSlice(data []any, node *Model) (*Model, error) {
return nil, errors.Wrap(err, "failed to push at index '%d'", i)
}
case []any:
- n := New(stdModel.ModelTypeList, nil)
+ n, _ := New(LIST, nil)
child, err := importSlice(typedV, n)
if err != nil {
return nil, errors.Wrap(err, "failed to import nested slice at index '%d'", i)
@@ -69,12 +96,20 @@ func importSlice(data []any, node *Model) (*Model, error) {
return node, nil
}
+// importData is the entry point for all data import operations triggered by
+// [New] and [Model.UnmarshalJSON]. It dispatches to [importMap] or
+// [importSlice] based on the concrete type of data.
+//
+// data must be map[string]any (for HASH models) or []any (for LIST models).
+// Any other type returns a non-nil error. The receiver mdl is used directly as
+// the import target; callers must not hold mdl's mutex.
func (mdl *Model) importData(data any) (*Model, error) {
switch typedData := data.(type) {
case map[string]any:
return importMap(typedData, mdl)
case []any:
return importSlice(typedData, mdl)
+ default:
+ return nil, errors.Errorf("cannot import data of type '%T'", data)
}
- return nil, nil
}
diff --git a/model.iterator.go b/model.iterator.go
index b16a12d..da7c92f 100644
--- a/model.iterator.go
+++ b/model.iterator.go
@@ -3,39 +3,56 @@ package model
import (
"github.com/bdlm/cast/v2"
"github.com/bdlm/errors/v2"
- stdModel "github.com/bdlm/std/v2/model"
)
-// Cur implements stdModel.Iterator.
+// Cur reads the key and value at the current cursor position into *pK and *pV
+// and returns true. It returns false — and leaves *pK and *pV unchanged — when
+// the cursor is before the first element (position -1) or beyond the last
+// element.
//
-// Cur reads the key and value at the current cursor position into pK and pV
-// respectively. Cur will return false if no iteration has begun, including
-// following calls to Reset.
+// The cursor is at -1 immediately after construction, after [Model.Reset], and
+// after any successful mutation (Delete, SetData, Sort, Reverse). Cur returns
+// false in all those states.
+//
+// For HASH models *pK is set to the string key; for LIST models *pK is set to
+// the integer index.
+//
+// Cur acquires a read lock and may run concurrently with other read operations.
+// Note that the cursor (pos) is a shared mutable value written by Next, Prev,
+// Reset, and Seek; concurrent iteration from multiple goroutines will
+// interleave cursor movements non-deterministically.
func (mdl *Model) Cur(pK, pV *any) bool {
+ mdl.mux.RLock()
+ defer mdl.mux.RUnlock()
if mdl.pos < 0 || mdl.pos >= len(mdl.data) {
return false
}
-
*pK = mdl.pos
- if stdModel.ModelTypeHash == mdl.GetType() {
+ if HASH == mdl.GetType() {
*pK = mdl.idxHash[mdl.pos]
}
- if tmp, ok := mdl.data[mdl.pos].(*Value); ok && nil != tmp {
- *pV = tmp
- } else {
- *pV = &Value{mdl.data[mdl.pos]}
- }
-
+ *pV = toValue(mdl.data[mdl.pos])
return true
}
-// Next implements stdModel.Iterator.
+// Next advances the cursor by one position and reads the key and value at the
+// new position into *pK and *pV. It returns true if an element was written, or
+// false when the cursor moves past the last element. When Next returns false it
+// also resets the cursor to -1, so the next Next call restarts from the
+// beginning of the data.
//
-// Next moves the cursor forward one position before reading the key and value
-// at the cursor position into pK and pV respectively. If data is available at
-// that position and was written to pK and pV then Next returns true, else
-// false to signify the end of the data and resets the cursor position to the
-// beginning of the data set (-1).
+// A typical forward-iteration loop:
+//
+// var key, val any
+// for m.Next(&key, &val) {
+// // process key and val.(stdModel.Value)
+// }
+//
+// For HASH models *pK is set to the string key; for LIST models *pK is set to
+// the integer index.
+//
+// Next acquires an exclusive write lock to update the cursor atomically with
+// the read of the current element.
func (mdl *Model) Next(pK, pV *any) bool {
mdl.mux.Lock()
mdl.pos++
@@ -48,63 +65,86 @@ func (mdl *Model) Next(pK, pV *any) bool {
}
*pK = mdl.pos
- if stdModel.ModelTypeHash == mdl.GetType() {
+ if HASH == mdl.GetType() {
*pK = mdl.idxHash[mdl.pos]
}
- if tmp, ok := mdl.data[mdl.pos].(*Value); ok && nil != tmp {
- *pV = tmp
- } else {
- *pV = &Value{mdl.data[mdl.pos]}
- }
+ *pV = toValue(mdl.data[mdl.pos])
mdl.mux.Unlock()
return true
}
-// Prev implements stdModel.Iterator.
+// Prev retreats the cursor by one position and reads the key and value at the
+// new position into *pK and *pV. It returns true if an element was written, or
+// false when the cursor would move before the first element. When Prev returns
+// false it clamps the cursor to -1 so that a subsequent [Model.Next] call
+// restarts from the beginning of the data rather than attempting a negative
+// slice index.
+//
+// A typical backward-iteration loop:
//
-// Prev moves the cursor backward one position before reading the key and value
-// at the cursor position into pK and pV respectively. If data is available at
-// that position and was written to pK and pV then Prev returns true, else
-// false to signify the beginning of the data.
+// m.Seek(m.Len() - 1) // start at the last element
+// var key, val any
+// for m.Prev(&key, &val) {
+// // process key and val.(stdModel.Value)
+// }
+//
+// For HASH models *pK is set to the string key; for LIST models *pK is set to
+// the integer index.
+//
+// Prev acquires an exclusive write lock to update the cursor atomically with
+// the read of the current element.
func (mdl *Model) Prev(pK, pV *any) bool {
mdl.mux.Lock()
mdl.pos--
- // at the beginning of the data, stop.
+ // at the beginning of the data, stop and clamp to -1 so that a
+ // subsequent Next() does not attempt a negative slice index.
if mdl.pos < 0 {
+ mdl.pos = -1
mdl.mux.Unlock()
return false
}
*pK = mdl.pos
- if stdModel.ModelTypeHash == mdl.GetType() {
+ if HASH == mdl.GetType() {
*pK = mdl.idxHash[mdl.pos]
}
- if tmp, ok := mdl.data[mdl.pos].(*Value); ok && nil != tmp {
- *pV = tmp
- } else {
- *pV = &Value{mdl.data[mdl.pos]}
- }
+ *pV = toValue(mdl.data[mdl.pos])
mdl.mux.Unlock()
return true
}
-// Reset implements stdModel.Iterator.
+// Reset sets the cursor to position -1, which is before the first element.
+// After Reset, [Model.Cur] returns false and the next [Model.Next] call
+// returns the first element.
//
-// Reset sets the iterator cursor to the position before the first element.
+// Reset acquires an exclusive write lock to update the cursor atomically.
func (mdl *Model) Reset() {
+ mdl.mux.Lock()
mdl.pos = -1
+ mdl.mux.Unlock()
}
-// Seek implements stdModel.Iterator.
+// Seek positions the cursor at pos so that [Model.Cur] immediately returns the
+// element at that position and the next [Model.Next] call returns the following
+// element.
+//
+// For LIST models, pos is converted to int via cast.ToE[int]; non-integer types
+// that cannot be cast return [InvalidIndexType]. A negative value or a value
+// >= [Model.Len] returns [InvalidIndex].
+//
+// For HASH models, pos is cast to string via bdlm/cast. If no element with
+// that key exists, [InvalidIndex] is returned.
//
-// Seek sets the iterator cursor to the specified position so that Cur returns
-// the element at that position.
+// Seek acquires an exclusive write lock to update the cursor atomically.
func (mdl *Model) Seek(pos any) error {
+ mdl.mux.Lock()
+ defer mdl.mux.Unlock()
+
// List model
- if stdModel.ModelTypeList == mdl.GetType() {
+ if LIST == mdl.GetType() {
idx, err := cast.ToE[int](pos)
if err != nil {
return errors.WrapE(InvalidIndexType, errors.Errorf("position '%v' must be an integer", pos))
@@ -120,7 +160,7 @@ func (mdl *Model) Seek(pos any) error {
}
// Hash model
- hashKey := pos.(string)
+ hashKey := cast.To[string](pos)
if idx, ok := mdl.hashIdx[hashKey]; ok {
mdl.pos = idx
return nil
diff --git a/model.marshaler.go b/model.marshaler.go
index 011f685..0303424 100644
--- a/model.marshaler.go
+++ b/model.marshaler.go
@@ -4,14 +4,26 @@ import (
"encoding/json"
"github.com/bdlm/errors/v2"
- stdModel "github.com/bdlm/std/v2/model"
)
// MarshalJSON implements json.Marshaler.
+//
+// For LIST models the data slice is marshaled as a JSON array. For HASH models
+// a map[string]any is assembled from the index maps and marshaled as a JSON
+// object. Because encoding/json sorts map keys alphabetically, the JSON key
+// order is always alphabetical regardless of the model's current internal
+// element order.
+//
+// Each stored *[Value] is marshaled via its own MarshalJSON method, which
+// serializes the underlying data directly rather than as a struct literal with
+// unexported fields.
+//
+// MarshalJSON acquires a read lock and may run concurrently with other read
+// operations.
func (mdl *Model) MarshalJSON() ([]byte, error) {
- mdl.mux.Lock()
- defer mdl.mux.Unlock()
- if stdModel.ModelTypeList == mdl.GetType() {
+ mdl.mux.RLock()
+ defer mdl.mux.RUnlock()
+ if LIST == mdl.GetType() {
return json.Marshal(mdl.data)
}
d := map[string]any{}
@@ -21,25 +33,60 @@ func (mdl *Model) MarshalJSON() ([]byte, error) {
return json.Marshal(d)
}
-// MarshalModel implements Marshaler.
+// MarshalModel implements the Marshaler interface. It delegates directly to
+// [Model.MarshalJSON] and is provided to satisfy custom serialization
+// interfaces that distinguish between the standard json.Marshaler and a
+// package-specific Marshaler.
func (mdl *Model) MarshalModel() ([]byte, error) {
return mdl.MarshalJSON()
}
// UnmarshalJSON implements json.Unmarshaler.
+//
+// The input bytes are first decoded into a raw Go value (map[string]any for
+// objects, []any for arrays) using encoding/json. The result is then imported
+// into a temporary model of the same type as the receiver. Once import
+// succeeds, the receiver's data, hashIdx, idxHash, and pos fields are replaced
+// atomically under a single write lock. This swap ensures that concurrent
+// readers never observe an empty intermediate state between the old and new
+// data.
+//
+// Calling UnmarshalJSON on a non-empty model replaces all existing data; it
+// does not merge. Hash keys are sorted alphabetically after import.
+//
+// Returns [ReadOnlyModel] if the model is locked. Returns an error if the JSON
+// cannot be decoded or if the decoded type is incompatible with the model type
+// (for example, a JSON array passed to a HASH model).
func (mdl *Model) UnmarshalJSON(jsn []byte) error {
- var data any
-
- if err := json.Unmarshal(jsn, &data); err != nil {
+ if err := mdl.checkLocked(); err != nil {
+ return err
+ }
+ var raw any
+ if err := json.Unmarshal(jsn, &raw); err != nil {
return errors.Wrap(err, "unmarshaling failed")
}
- if _, err := mdl.importData(data); err != nil {
+ // Build into a temporary model (same type), then atomically swap internals.
+ tmp, _ := New(mdl.GetType(), nil)
+ if _, err := tmp.importData(raw); err != nil {
return errors.Wrap(err, "import failed")
}
+ mdl.mux.Lock()
+ if err := mdl.checkLocked(); err != nil { // double-check inside lock
+ mdl.mux.Unlock()
+ return err
+ }
+ mdl.data = tmp.data
+ mdl.hashIdx = tmp.hashIdx
+ mdl.idxHash = tmp.idxHash
+ mdl.pos = -1
+ mdl.mux.Unlock()
return nil
}
-// UnmarshalModel implements Unmarshaler.
+// UnmarshalModel implements the Unmarshaler interface. It is a thin wrapper
+// around [Model.UnmarshalJSON] that treats the JSON null literal as a no-op,
+// leaving the model's existing data intact. All other input is forwarded to
+// UnmarshalJSON.
func (mdl *Model) UnmarshalModel(bytes []byte) error {
if string(bytes) == "null" {
return nil
diff --git a/model.sorter.go b/model.sorter.go
index 6237d74..c77e4e3 100644
--- a/model.sorter.go
+++ b/model.sorter.go
@@ -10,24 +10,47 @@ import (
stdSorter "github.com/bdlm/std/v2/sorter"
)
-// Len returns the number of items stored in this model.
+// Len returns the number of elements currently stored in the model. It
+// acquires a read lock and may run concurrently with other read operations.
func (mdl *Model) Len() int {
+ mdl.mux.RLock()
+ defer mdl.mux.RUnlock()
return len(mdl.data)
}
-// Reverse reverses the order of the data store.
+// Reverse reverses the order of all elements in the model in place.
+//
+// For HASH models both index maps (hashIdx and idxHash) are rebuilt to reflect
+// the new positions; all existing keys remain accessible at their reversed
+// positions. For LIST models the data slice is reversed in place.
+//
+// A successful Reverse resets the cursor to -1. Returns [ReadOnlyModel] if the
+// model is locked.
func (mdl *Model) Reverse() error {
- if mdl.locked {
- return errors.WrapE(ReadOnlyProperty, errors.Errorf("model is locked"))
+ if err := mdl.checkLocked(); err != nil {
+ return err
}
mdl.mux.Lock()
defer mdl.mux.Unlock()
+ if err := mdl.checkLocked(); err != nil {
+ return err
+ }
+ mdl.reverse()
+ mdl.pos = -1 // mutation invalidates cursor
+ return nil
+}
+// reverse is the write-lock-held implementation of [Model.Reverse]. Callers
+// must hold mdl.mux exclusively. It reverses the data slice and, for HASH
+// models, rebuilds both index maps so that each key continues to resolve to
+// its (now-reversed) position. The cursor is not touched here; callers are
+// responsible for resetting it.
+func (mdl *Model) reverse() {
n := len(mdl.data)
for i, j := 0, n-1; i < j; i, j = i+1, j-1 {
mdl.data[i], mdl.data[j] = mdl.data[j], mdl.data[i]
}
- if stdModel.ModelTypeHash == mdl.GetType() {
+ if HASH == mdl.GetType() {
newHashIdx := make(map[string]int, n)
newIdxHash := make(map[int]string, n)
for oldIdx, key := range mdl.idxHash {
@@ -38,34 +61,104 @@ func (mdl *Model) Reverse() error {
mdl.hashIdx = newHashIdx
mdl.idxHash = newIdxHash
}
- return nil
}
-// Sort sorts the model data by the specified flag.
+// Sort reorders the model's elements according to flag. It returns
+// [ReadOnlyModel] if the model is locked, and [InvalidSortFlagCombination] if
+// mutually exclusive flags are combined.
+//
+// # Flag semantics
+//
+// SortByValue (== 0) is the implicit default for value-based sorting. It
+// contributes no bits of its own; value-based sorting is triggered by
+// providing SortAsc, SortDesc, or SortAsString without SortByKey. Calling
+// Sort(0) or Sort(SortByValue) is therefore a no-op.
+//
+// - SortByKey (1): For HASH models, sort alphabetically by key, ascending
+// unless SortDesc is also set. For LIST models, SortByKey alone is a
+// no-op; combine with SortAsString to sort list elements by their string
+// representations.
+// - SortAsc (2): Ascending order. Triggers value-based sorting when used
+// without SortByKey.
+// - SortDesc (4): Descending order. Triggers value-based sorting when used
+// without SortByKey.
+// - SortAsString (8): Compare using string representations via bdlm/cast
+// rather than the default type-stratified ordering. Triggers value-based
+// sorting when used without SortByKey.
+// - SortReverse (16): Reverse the final result after all other sorting is
+// complete. May be combined with any other flag.
+//
+// The only invalid combination is SortAsc | SortDesc.
+//
+// # Value-based type-stratified ordering (ascending)
+//
+// *Model < nil < false < true < numeric < string < other
+//
+// Within the *Model bucket, models are compared first by GetID() (cast to
+// string) then by element count. Within the numeric bucket, all values are
+// widened to float64 before comparison.
+//
+// # Cursor behavior
+//
+// The cursor is reset to -1 only when data ordering actually changes. A no-op
+// call (e.g., Sort(0) or SortByKey on a LIST without SortAsString) leaves the
+// cursor unchanged.
func (mdl *Model) Sort(flag stdSorter.SortFlag) error {
- if mdl.locked {
- return errors.WrapE(ReadOnlyProperty, errors.Errorf("model is locked"))
+ if err := mdl.checkLocked(); err != nil {
+ return err
+ }
+ if flag&stdSorter.SortAsc != 0 && flag&stdSorter.SortDesc != 0 {
+ return errors.WrapE(InvalidSortFlagCombination, errors.Errorf("SortAsc and SortDesc cannot be combined"))
}
+ desc := flag&stdSorter.SortDesc != 0
+
+ mdl.mux.Lock()
+ defer mdl.mux.Unlock()
+ if err := mdl.checkLocked(); err != nil { // double-check inside lock
+ return err
+ }
+
+ // pos is reset only when data ordering actually changes.
+ didSort := false
+
if flag&stdSorter.SortByKey != 0 {
- if stdModel.ModelTypeHash == mdl.GetType() {
+ if HASH == mdl.GetType() {
keys := make([]string, 0, len(mdl.idxHash))
for _, k := range mdl.idxHash {
keys = append(keys, k)
}
- sort.Strings(keys)
+ sort.Strings(keys) // always collect ascending first
+ if desc {
+ for i, j := 0, len(keys)-1; i < j; i, j = i+1, j-1 {
+ keys[i], keys[j] = keys[j], keys[i]
+ }
+ }
mdl.data, mdl.hashIdx, mdl.idxHash = rebuildHashIndexes(keys, mdl)
+ didSort = true
+ }
+ // For list models, SortByKey alone is a no-op (integer keys are always
+ // positional). SortByKey|SortAsString sorts list values as strings.
+ if LIST == mdl.GetType() && flag&stdSorter.SortAsString != 0 {
+ compare := lessFn(stringLess, desc)
+ sort.SliceStable(mdl.data, func(i, j int) bool {
+ return compare(mdl.data[i], mdl.data[j])
+ })
+ didSort = true
}
- // list: integer keys are always positional, SortByKey is a no-op
}
- if flag&stdSorter.SortByValue != 0 {
- compare := stratifiedLess
+ // SortByValue is the zero-value default: sort by value whenever SortByKey is
+ // not set and at least one sort-relevant modifier (direction or SortAsString)
+ // is present. A bare flag==0 (or flag==SortByValue) is a no-op.
+ if flag&stdSorter.SortByKey == 0 && flag&(stdSorter.SortAsc|stdSorter.SortDesc|stdSorter.SortAsString) != 0 {
+ base := stratifiedLess
if flag&stdSorter.SortAsString != 0 {
- compare = stringLess
+ base = stringLess
}
+ compare := lessFn(base, desc)
- if stdModel.ModelTypeHash == mdl.GetType() {
+ if HASH == mdl.GetType() {
keys := make([]string, 0, len(mdl.idxHash))
for _, k := range mdl.idxHash {
keys = append(keys, k)
@@ -74,25 +167,43 @@ func (mdl *Model) Sort(flag stdSorter.SortFlag) error {
return compare(mdl.data[mdl.hashIdx[keys[i]]], mdl.data[mdl.hashIdx[keys[j]]])
})
mdl.data, mdl.hashIdx, mdl.idxHash = rebuildHashIndexes(keys, mdl)
+ didSort = true
}
- if stdModel.ModelTypeList == mdl.GetType() {
+ if LIST == mdl.GetType() {
sort.SliceStable(mdl.data, func(i, j int) bool {
return compare(mdl.data[i], mdl.data[j])
})
+ didSort = true
}
}
if flag&stdSorter.SortReverse != 0 {
- if err := mdl.Reverse(); err != nil {
- return errors.Wrap(err, "failed to reverse data")
- }
+ mdl.reverse()
+ didSort = true
+ }
+
+ if didSort {
+ mdl.pos = -1
}
return nil
}
-// modelLen returns the number of elements in a Model, or 0 for other types.
+// lessFn adapts a less-than comparator for ascending or descending order. When
+// desc is false the original function is returned unchanged. When desc is true
+// a wrapper is returned that swaps the arguments, effectively reversing the
+// comparison without rewriting the underlying comparator.
+func lessFn(fn func(a, b any) bool, desc bool) func(a, b any) bool {
+ if !desc {
+ return fn
+ }
+ return func(a, b any) bool { return fn(b, a) }
+}
+
+// modelLen returns the number of elements in v if v implements stdModel.Model,
+// or 0 otherwise. It is used as a tiebreaker in [stratifiedLess] when two
+// models have the same GetID value.
func modelLen(v any) int {
if m, ok := v.(stdModel.Model); ok {
d, _, _ := m.GetData()
@@ -101,8 +212,13 @@ func modelLen(v any) int {
return 0
}
-// rebuildHashIndexes rebuilds hashIdx and idxHash from an ordered key slice
-// after the data slice has been repopulated in that order.
+// rebuildHashIndexes constructs a new data slice and both index maps from an
+// ordered key slice, reading the associated values from src. It is called
+// after any in-place reordering of a HASH model (Sort, Reverse) to keep
+// data, hashIdx, and idxHash mutually consistent.
+//
+// Callers must hold src's write lock and must ensure that every key in keys is
+// present in src.hashIdx.
func rebuildHashIndexes(keys []string, src *Model) ([]any, map[string]int, map[int]string) {
data := make([]any, 0, len(keys))
hashIdx := make(map[string]int, len(keys))
@@ -115,9 +231,19 @@ func rebuildHashIndexes(keys []string, src *Model) ([]any, map[string]int, map[i
return data, hashIdx, idxHash
}
-// stratifiedLess compares two values using type-stratified ordering:
-// Model/*Model < nil < bool < numeric < string < other. Within each bucket,
-// values are compared naturally (bool: false 2
+ })
+ if mLen(result) != 2 {
+ t.Errorf("expected len=2, got %d", mLen(result))
+ }
+ if result.GetType() != model.HASH {
+ t.Error("Filter should return same model type")
+ }
+}
+
+func TestFilterPreservesOriginal(t *testing.T) {
+ m := mustNew(t, model.LIST, []any{1, 2, 3, 4})
+ m.Filter(func(v stdModel.Value) bool { return true })
+ if m.Len() != 4 {
+ t.Errorf("Filter should not modify original; expected len=4, got %d", m.Len())
+ }
+}
+
+func TestFilterCallbackCanReadModel(t *testing.T) {
+ m := mustNew(t, model.LIST, []any{1, 2, 3})
+ // Callback calls Get on the same model — would deadlock before the snapshot fix.
+ result := m.Filter(func(v stdModel.Value) bool {
+ n, _ := v.Int()
+ // read from the model being filtered — safe with snapshot approach
+ first, err := m.Get(0)
+ if err != nil {
+ return false
+ }
+ f, _ := first.Int()
+ return n > f // include elements greater than the first
+ })
+ // first element is 1, so elements 2 and 3 pass
+ if mLen(result) != 2 {
+ t.Errorf("expected len=2, got %d", mLen(result))
+ }
+}
+
+func TestFilterCallbackCanWriteModel(t *testing.T) {
+ m := mustNew(t, model.LIST, []any{1, 2, 3})
+ seen := 0
+ m.Filter(func(v stdModel.Value) bool {
+ seen++
+ // write to the model being filtered — safe with snapshot approach
+ m.Push(seen * 10)
+ return false
+ })
+ // snapshot had 3 elements, so 3 pushes happened
+ if m.Len() != 6 {
+ t.Errorf("expected model len=6 after filter writes, got %d", m.Len())
+ }
+}
+
+// ── Map ───────────────────────────────────────────────────────────────────────
+
+func TestMapList(t *testing.T) {
+ m := mustNew(t, model.LIST, []any{1, 2, 3})
+ result := m.Map(func(v stdModel.Value) stdModel.Value {
+ n, _ := v.Int()
+ doubled, _ := model.New(model.LIST, nil)
+ doubled.Push(n * 2)
+ val, _ := doubled.Get(0)
+ return val
+ })
+ if mLen(result) != 3 {
+ t.Errorf("expected len=3, got %d", mLen(result))
+ }
+ v, _ := result.Get(0)
+ if intVal(t, v) != 2 {
+ t.Errorf("expected first element=2, got %d", intVal(t, v))
+ }
+}
+
+func TestMapPreservesHashKeys(t *testing.T) {
+ m, _ := model.New(model.HASH, map[string]any{"a": 1, "b": 2})
+ result := m.Map(func(v stdModel.Value) stdModel.Value {
+ n, _ := v.Int()
+ tmp, _ := model.New(model.LIST, nil)
+ tmp.Push(n * 10)
+ r, _ := tmp.Get(0)
+ return r
+ })
+ if !result.Has("a") || !result.Has("b") {
+ t.Error("Map should preserve hash keys")
+ }
+}
+
+func TestMapPreservesOriginal(t *testing.T) {
+ m := mustNew(t, model.LIST, []any{1, 2, 3})
+ m.Map(func(v stdModel.Value) stdModel.Value { return v })
+ if m.Len() != 3 {
+ t.Errorf("Map should not modify original; expected len=3, got %d", m.Len())
+ }
+}
+
+func TestMapCallbackCanReadModel(t *testing.T) {
+ m := mustNew(t, model.LIST, []any{10, 20, 30})
+ // Callback reads the model — safe with snapshot approach.
+ result := m.Map(func(v stdModel.Value) stdModel.Value {
+ n, _ := v.Int()
+ first, _ := m.Get(0)
+ base, _ := first.Int()
+ tmp, _ := model.New(model.LIST, nil)
+ tmp.Push(n + base) // add first element (10) to each
+ r, _ := tmp.Get(0)
+ return r
+ })
+ v, _ := result.Get(0)
+ if intVal(t, v) != 20 {
+ t.Errorf("expected 20, got %d", intVal(t, v))
+ }
+ v, _ = result.Get(1)
+ if intVal(t, v) != 30 {
+ t.Errorf("expected 30, got %d", intVal(t, v))
+ }
+}
+
+// ── Reduce ────────────────────────────────────────────────────────────────────
+
+func TestReduceSum(t *testing.T) {
+ m := mustNew(t, model.LIST, []any{1, 2, 3, 4, 5})
+ result := m.Reduce(func(carry, cur stdModel.Value) stdModel.Value {
+ a, _ := carry.Int()
+ b, _ := cur.Int()
+ tmp, _ := model.New(model.LIST, nil)
+ tmp.Push(a + b)
+ v, _ := tmp.Get(0)
+ return v
+ })
+ if intVal(t, result) != 15 {
+ t.Errorf("expected sum=15, got %d", intVal(t, result))
+ }
+}
+
+func TestReduceEmpty(t *testing.T) {
+ m := mustNew(t, model.LIST, nil)
+ result := m.Reduce(func(carry, cur stdModel.Value) stdModel.Value {
+ return carry
+ })
+ if result != nil {
+ t.Errorf("expected nil for empty model, got %v", result)
+ }
+}
+
+func TestReduceSingleElement(t *testing.T) {
+ m := mustNew(t, model.LIST, []any{42})
+ result := m.Reduce(func(carry, cur stdModel.Value) stdModel.Value {
+ return cur
+ })
+ if intVal(t, result) != 42 {
+ t.Errorf("expected 42, got %d", intVal(t, result))
+ }
+}
+
+func TestReduceCallbackCanReadModel(t *testing.T) {
+ m := mustNew(t, model.LIST, []any{1, 2, 3})
+ // Callback reads the model — safe with snapshot approach.
+ m.Reduce(func(carry, cur stdModel.Value) stdModel.Value {
+ m.Len() // reading the model during reduce
+ return carry
+ })
+}
+
+// ── JSON marshal/unmarshal ────────────────────────────────────────────────────
+
+func TestJSONRoundTrip(t *testing.T) {
+ m := mustNew(t, model.HASH, nil)
+ m.Set("str", "hello")
+ m.Set("num", 42)
+
+ b, err := json.Marshal(m)
+ if err != nil {
+ t.Fatalf("Marshal: %v", err)
+ }
+
+ m2 := mustNew(t, model.HASH, nil)
+ if err := json.Unmarshal(b, m2); err != nil {
+ t.Fatalf("Unmarshal: %v", err)
+ }
+
+ v, _ := m2.Get("str")
+ if strVal(t, v) != "hello" {
+ t.Errorf("expected 'hello', got %q", strVal(t, v))
+ }
+ v, _ = m2.Get("num")
+ if n, _ := v.Float(); n != 42 {
+ t.Errorf("expected 42, got %v", n)
+ }
+}
+
+func TestJSONNestedModel(t *testing.T) {
+ m := mustNew(t, model.HASH, nil)
+ json.Unmarshal([]byte(`{"users":[{"name":"Alice"},{"name":"Bob"}]}`), m)
+
+ v, err := m.Get("users")
+ if err != nil {
+ t.Fatalf("Get('users'): %v", err)
+ }
+ users, err := v.Model()
+ if err != nil {
+ t.Fatalf("Model(): %v", err)
+ }
+ if mLen(users) != 2 {
+ t.Errorf("expected 2 users, got %d", mLen(users))
+ }
+}
+
+func TestUnmarshalReplacesExistingData(t *testing.T) {
+ m := mustNew(t, model.HASH, nil)
+ m.Set("old", 99)
+ json.Unmarshal([]byte(`{"new":1}`), m)
+ if m.Has("old") {
+ t.Error("UnmarshalJSON should replace existing data, not append")
+ }
+ if !m.Has("new") {
+ t.Error("'new' key should exist after unmarshal")
+ }
+}
+
+func TestUnmarshalLockedModel(t *testing.T) {
+ m := mustNew(t, model.HASH, nil)
+ m.Lock()
+ err := json.Unmarshal([]byte(`{"k":1}`), m)
+ if err == nil {
+ t.Error("expected error unmarshaling into locked model")
+ }
+}
+
+// ── Concurrency ───────────────────────────────────────────────────────────────
+
+func TestConcurrentReads(t *testing.T) {
+ m := mustNew(t, model.HASH, nil)
+ for i := 0; i < 100; i++ {
+ m.Set(fmt.Sprintf("k%d", i), i)
+ }
+ var wg sync.WaitGroup
+ for i := 0; i < 50; i++ {
+ wg.Add(1)
+ go func(key string) {
+ defer wg.Done()
+ m.Get(key)
+ m.Has(key)
+ m.Len()
+ }(fmt.Sprintf("k%d", i))
+ }
+ wg.Wait()
+}
+
+func TestConcurrentWrites(t *testing.T) {
+ m := mustNew(t, model.LIST, nil)
+ var wg sync.WaitGroup
+ for i := 0; i < 100; i++ {
+ wg.Add(1)
+ go func(n int) {
+ defer wg.Done()
+ m.Push(n)
+ }(i)
+ }
+ wg.Wait()
+ if m.Len() != 100 {
+ t.Errorf("expected len=100 after 100 concurrent pushes, got %d", m.Len())
+ }
+}
+
+func TestFilterCallbackNoDeadlock(t *testing.T) {
+ m := mustNew(t, model.LIST, []any{1, 2, 3, 4, 5})
+ done := make(chan struct{})
+ go func() {
+ m.Filter(func(v stdModel.Value) bool {
+ n, _ := v.Int()
+ // Reading and writing the model from inside the callback.
+ // Both would deadlock before the snapshot fix.
+ m.Has(0)
+ m.Push(n * 100)
+ return n%2 == 0
+ })
+ close(done)
+ }()
+ select {
+ case <-done:
+ // passed
+ case <-waitTimeout(2 * time.Second):
+ t.Fatal("Filter deadlocked — callback blocked on model mutex")
+ }
+}
+
+func TestMapCallbackNoDeadlock(t *testing.T) {
+ m := mustNew(t, model.LIST, []any{1, 2, 3})
+ done := make(chan struct{})
+ go func() {
+ m.Map(func(v stdModel.Value) stdModel.Value {
+ m.Len() // would deadlock before fix
+ return v
+ })
+ close(done)
+ }()
+ select {
+ case <-done:
+ case <-waitTimeout(2 * time.Second):
+ t.Fatal("Map deadlocked — callback blocked on model mutex")
+ }
+}
+
+func TestReduceCallbackNoDeadlock(t *testing.T) {
+ m := mustNew(t, model.LIST, []any{1, 2, 3})
+ done := make(chan struct{})
+ go func() {
+ m.Reduce(func(carry, cur stdModel.Value) stdModel.Value {
+ m.Len() // would deadlock before fix
+ return carry
+ })
+ close(done)
+ }()
+ select {
+ case <-done:
+ case <-waitTimeout(2 * time.Second):
+ t.Fatal("Reduce deadlocked — callback blocked on model mutex")
+ }
+}
+
+// ── Value accessors ────────────────────────────────────────────────────────────
+
+func TestValueBoolSuccess(t *testing.T) {
+ m := mustNew(t, model.LIST, nil)
+ m.Push(1) // 1 → true via cast
+ v, _ := m.Get(0)
+ b, err := v.Bool()
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !b {
+ t.Error("expected true from int(1)")
+ }
+}
+
+func TestValueBoolError(t *testing.T) {
+ m := mustNew(t, model.LIST, nil)
+ m.Push("not-a-bool")
+ v, _ := m.Get(0)
+ _, err := v.Bool()
+ if err == nil {
+ t.Error("expected error converting 'not-a-bool' to bool")
+ }
+}
+
+func TestValueIntFromFloat(t *testing.T) {
+ m := mustNew(t, model.LIST, nil)
+ m.Push(3.7)
+ v, _ := m.Get(0)
+ n, err := v.Int()
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if n != 3 { // truncated toward zero
+ t.Errorf("expected 3 (truncated), got %d", n)
+ }
+}
+
+func TestValueIntError(t *testing.T) {
+ m := mustNew(t, model.LIST, nil)
+ m.Push("not-a-number")
+ v, _ := m.Get(0)
+ _, err := v.Int()
+ if err == nil {
+ t.Error("expected error converting 'not-a-number' to int")
+ }
+}
+
+func TestValueFloat32Success(t *testing.T) {
+ m := mustNew(t, model.LIST, nil)
+ m.Push(3.14)
+ v, _ := m.Get(0)
+ f, err := v.Float32()
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if f < 3.13 || f > 3.15 {
+ t.Errorf("expected ~3.14, got %v", f)
+ }
+}
+
+func TestValueFloat64Success(t *testing.T) {
+ m := mustNew(t, model.LIST, nil)
+ m.Push(2.718281828)
+ v, _ := m.Get(0)
+ f, err := v.Float64()
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if f < 2.71 || f > 2.72 {
+ t.Errorf("expected ~2.718, got %v", f)
+ }
+}
+
+func TestValueStringFromInt(t *testing.T) {
+ m := mustNew(t, model.LIST, nil)
+ m.Push(42)
+ v, _ := m.Get(0)
+ s, err := v.String()
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if s != "42" {
+ t.Errorf("expected '42', got %q", s)
+ }
+}
+
+func TestValueListError(t *testing.T) {
+ m := mustNew(t, model.LIST, nil)
+ m.Push("not-a-list")
+ v, _ := m.Get(0)
+ _, err := v.List()
+ if err == nil {
+ t.Error("expected error from List() on a string value")
+ }
+}
+
+func TestValueMapError(t *testing.T) {
+ m := mustNew(t, model.LIST, nil)
+ m.Push("not-a-map")
+ v, _ := m.Get(0)
+ _, err := v.Map()
+ if err == nil {
+ t.Error("expected error from Map() on a string value")
+ }
+}
+
+func TestValueModelError(t *testing.T) {
+ m := mustNew(t, model.LIST, nil)
+ m.Push("not-a-model")
+ v, _ := m.Get(0)
+ _, err := v.Model()
+ if err == nil {
+ t.Error("expected error from Model() on a string value")
+ }
+}
+
+func TestValueValueReturnsRaw(t *testing.T) {
+ m := mustNew(t, model.LIST, nil)
+ m.Push(42)
+ v, _ := m.Get(0)
+ raw := v.Value()
+ n, ok := raw.(int)
+ if !ok {
+ t.Fatalf("expected int from Value(), got %T", raw)
+ }
+ if n != 42 {
+ t.Errorf("expected 42, got %d", n)
+ }
+}
+
+// ── Additional error cases ─────────────────────────────────────────────────────
+
+func TestDeleteNonExistentHashKey(t *testing.T) {
+ m := mustNew(t, model.HASH, nil)
+ m.Set("a", 1)
+ err := m.Delete("nonexistent")
+ if err == nil {
+ t.Fatal("expected error deleting non-existent key")
+ }
+ if !errors.Is(err, model.InvalidIndex) {
+ t.Errorf("expected InvalidIndex, got %v", err)
+ }
+}
+
+func TestSetDataNilList(t *testing.T) {
+ m := mustNew(t, model.LIST, nil)
+ if err := m.SetData(nil); err == nil {
+ t.Fatal("expected error from SetData(nil) on list model")
+ } else if !errors.Is(err, model.InvalidDataSet) {
+ t.Errorf("expected InvalidDataSet, got %v", err)
+ }
+}
+
+func TestSetDataNilHash(t *testing.T) {
+ m := mustNew(t, model.HASH, nil)
+ if err := m.SetData(nil); err == nil {
+ t.Fatal("expected error from SetData(nil) on hash model")
+ } else if !errors.Is(err, model.InvalidDataSet) {
+ t.Errorf("expected InvalidDataSet, got %v", err)
+ }
+}
+
+func TestSeekNegativeList(t *testing.T) {
+ m := mustNew(t, model.LIST, []any{1, 2})
+ err := m.Seek(-1)
+ if err == nil {
+ t.Fatal("expected error from Seek(-1)")
+ }
+ if !errors.Is(err, model.InvalidIndex) {
+ t.Errorf("expected InvalidIndex, got %v", err)
+ }
+}
+
+func TestSeekMissingHashKey(t *testing.T) {
+ m, _ := model.New(model.HASH, map[string]any{"a": 1})
+ err := m.Seek("missing")
+ if err == nil {
+ t.Fatal("expected error from Seek on missing hash key")
+ }
+ if !errors.Is(err, model.InvalidIndex) {
+ t.Errorf("expected InvalidIndex, got %v", err)
+ }
+}
+
+func TestCurBeforeIteration(t *testing.T) {
+ m := mustNew(t, model.LIST, []any{1, 2, 3})
+ var key, val any
+ if m.Cur(&key, &val) {
+ t.Error("Cur should return false before any iteration begins")
+ }
+}
+
+// ── Additional method coverage ─────────────────────────────────────────────────
+
+func TestMarshalModel(t *testing.T) {
+ m := mustNew(t, model.HASH, nil)
+ m.Set("key", "value")
+ b, err := m.MarshalModel()
+ if err != nil {
+ t.Fatalf("MarshalModel: %v", err)
+ }
+ expected, _ := json.Marshal(m)
+ if string(b) != string(expected) {
+ t.Errorf("MarshalModel output differs from MarshalJSON\ngot: %s\nwant: %s", b, expected)
+ }
+}
+
+func TestUnmarshalModel(t *testing.T) {
+ m := mustNew(t, model.HASH, nil)
+ m.Set("x", 99)
+ b, _ := json.Marshal(m)
+
+ m2 := mustNew(t, model.HASH, nil)
+ if err := m2.UnmarshalModel(b); err != nil {
+ t.Fatalf("UnmarshalModel: %v", err)
+ }
+ v, err := m2.Get("x")
+ if err != nil {
+ t.Fatalf("Get('x'): %v", err)
+ }
+ if n, _ := v.Float(); n != 99 {
+ t.Errorf("expected 99, got %v", n)
+ }
+}
+
+func TestUnmarshalModelNull(t *testing.T) {
+ m := mustNew(t, model.HASH, nil)
+ m.Set("existing", 1)
+ if err := m.UnmarshalModel([]byte("null")); err != nil {
+ t.Fatalf("UnmarshalModel(null) should be a no-op: %v", err)
+ }
+ if !m.Has("existing") {
+ t.Error("UnmarshalModel(null) should not modify the model")
+ }
+}
+
+func TestUnmarshalModelLockedModel(t *testing.T) {
+ m := mustNew(t, model.HASH, nil)
+ m.Lock()
+ err := m.UnmarshalModel([]byte(`{"k":1}`))
+ if err == nil {
+ t.Fatal("expected error from UnmarshalModel on locked model")
+ }
+ if !errors.Is(err, model.ReadOnlyModel) {
+ t.Errorf("expected ReadOnlyModel, got %v", err)
+ }
+}
+
+func TestGetTypeAfterSetType(t *testing.T) {
+ m := mustNew(t, model.HASH, nil)
+ if m.GetType() != model.HASH {
+ t.Errorf("expected HASH, got %v", m.GetType())
+ }
+ if err := m.SetType(model.LIST); err != nil {
+ t.Fatalf("SetType: %v", err)
+ }
+ if m.GetType() != model.LIST {
+ t.Errorf("expected LIST after SetType, got %v", m.GetType())
+ }
+}
+
+func TestFilterEmpty(t *testing.T) {
+ m := mustNew(t, model.LIST, nil)
+ result := m.Filter(func(v stdModel.Value) bool { return true })
+ if mLen(result) != 0 {
+ t.Errorf("expected empty result from Filter on empty model, got %d", mLen(result))
+ }
+}
+
+func TestFilterListValues(t *testing.T) {
+ m := mustNew(t, model.LIST, []any{1, 2, 3, 4, 5, 6})
+ result := m.Filter(func(v stdModel.Value) bool {
+ n, _ := v.Int()
+ return n%2 == 0
+ })
+ if mLen(result) != 3 {
+ t.Fatalf("expected 3 elements, got %d", mLen(result))
+ }
+ for i, expected := range []int{2, 4, 6} {
+ v, err := result.Get(i)
+ if err != nil {
+ t.Fatalf("Get(%d): %v", i, err)
+ }
+ if n, _ := v.Int(); n != expected {
+ t.Errorf("index %d: expected %d, got %d", i, expected, n)
+ }
+ }
+}
+
+func TestFilterHashValues(t *testing.T) {
+ m, _ := model.New(model.HASH, map[string]any{"a": 1, "b": 2, "c": 3, "d": 4})
+ result := m.Filter(func(v stdModel.Value) bool {
+ n, _ := v.Int()
+ return n > 2
+ })
+ if !result.Has("c") || !result.Has("d") {
+ t.Error("expected keys 'c' and 'd' in filter result")
+ }
+ if result.Has("a") || result.Has("b") {
+ t.Error("keys 'a' and 'b' should be filtered out")
+ }
+}
+
+func TestMapEmpty(t *testing.T) {
+ m := mustNew(t, model.LIST, nil)
+ result := m.Map(func(v stdModel.Value) stdModel.Value { return v })
+ if mLen(result) != 0 {
+ t.Errorf("expected empty result from Map on empty model, got %d", mLen(result))
+ }
+}
+
+func TestMapHashValues(t *testing.T) {
+ m, _ := model.New(model.HASH, map[string]any{"a": 1, "b": 2})
+ result := m.Map(func(v stdModel.Value) stdModel.Value {
+ n, _ := v.Int()
+ tmp, _ := model.New(model.LIST, nil)
+ tmp.Push(n * 10)
+ r, _ := tmp.Get(0)
+ return r
+ })
+ va, _ := result.Get("a")
+ if intVal(t, va) != 10 {
+ t.Errorf("expected 'a'→10, got %d", intVal(t, va))
+ }
+ vb, _ := result.Get("b")
+ if intVal(t, vb) != 20 {
+ t.Errorf("expected 'b'→20, got %d", intVal(t, vb))
+ }
+}
+
+func TestReduceHash(t *testing.T) {
+ // Hash is sorted by key after import (a=1, b=2, c=3), so iteration is deterministic.
+ m, _ := model.New(model.HASH, map[string]any{"a": 1, "b": 2, "c": 3})
+ result := m.Reduce(func(carry, cur stdModel.Value) stdModel.Value {
+ a, _ := carry.Int()
+ b, _ := cur.Int()
+ tmp, _ := model.New(model.LIST, nil)
+ tmp.Push(a + b)
+ v, _ := tmp.Get(0)
+ return v
+ })
+ if intVal(t, result) != 6 {
+ t.Errorf("expected sum=6, got %d", intVal(t, result))
+ }
+}
+
+// ── Additional sort coverage ───────────────────────────────────────────────────
+
+func TestSortByValueHash(t *testing.T) {
+ m := mustNew(t, model.HASH, nil)
+ m.Set("b", 3)
+ m.Set("a", 1)
+ m.Set("c", 2)
+ m.Sort(stdSorter.SortAsc)
+ var vals []int
+ var key, val any
+ for m.Next(&key, &val) {
+ n, _ := val.(stdModel.Value).Int()
+ vals = append(vals, n)
+ }
+ if len(vals) != 3 || vals[0] != 1 || vals[1] != 2 || vals[2] != 3 {
+ t.Errorf("expected [1 2 3] after SortByValue on hash, got %v", vals)
+ }
+}
+
+func TestSortByValueAsString(t *testing.T) {
+ m := mustNew(t, model.LIST, []any{30, 10, 200})
+ m.Sort(stdSorter.SortByValue | stdSorter.SortAsString)
+ v0, _ := m.Get(0)
+ v1, _ := m.Get(1)
+ v2, _ := m.Get(2)
+ // lexicographic: "10" < "200" < "30"
+ if intVal(t, v0) != 10 || intVal(t, v1) != 200 || intVal(t, v2) != 30 {
+ t.Errorf("expected [10 200 30] (lexicographic), got [%d %d %d]", intVal(t, v0), intVal(t, v1), intVal(t, v2))
+ }
+}
+
+func TestSortByKeyListNoOp(t *testing.T) {
+ m := mustNew(t, model.LIST, []any{3, 1, 2})
+ m.Sort(stdSorter.SortByKey)
+ v0, _ := m.Get(0)
+ v1, _ := m.Get(1)
+ v2, _ := m.Get(2)
+ if intVal(t, v0) != 3 || intVal(t, v1) != 1 || intVal(t, v2) != 2 {
+ t.Errorf("SortByKey on list should be a no-op, got [%d %d %d]", intVal(t, v0), intVal(t, v1), intVal(t, v2))
+ }
+}
+
+func TestSortByValueMixedTypes(t *testing.T) {
+ // Stratified order: bool(false) < numeric(1) < string("apple") < string("banana")
+ m := mustNew(t, model.LIST, nil)
+ m.Push("banana")
+ m.Push(1)
+ m.Push(false)
+ m.Push("apple")
+ m.Sort(stdSorter.SortAsc)
+
+ v0, _ := m.Get(0)
+ b, _ := v0.Bool()
+ if b != false {
+ t.Errorf("expected false at index 0 (bool bucket), got %v", v0.Value())
+ }
+ v1, _ := m.Get(1)
+ if intVal(t, v1) != 1 {
+ t.Errorf("expected 1 at index 1 (numeric bucket), got %v", v1.Value())
+ }
+ v2, _ := m.Get(2)
+ s2, _ := v2.String()
+ v3, _ := m.Get(3)
+ s3, _ := v3.String()
+ if s2 != "apple" || s3 != "banana" {
+ t.Errorf("expected [apple banana] at indices 2-3 (string bucket), got [%v %v]", s2, s3)
+ }
+}
+
+func TestSortAscExplicit(t *testing.T) {
+ m := mustNew(t, model.LIST, []any{3, 1, 2})
+ m.Sort(stdSorter.SortByValue | stdSorter.SortAsc)
+ v0, _ := m.Get(0)
+ if intVal(t, v0) != 1 {
+ t.Errorf("expected 1 at index 0 with explicit SortAsc, got %d", intVal(t, v0))
+ }
+}
+
+// ── Additional lock enforcement ────────────────────────────────────────────────
+
+func TestLockPreventsMerge(t *testing.T) {
+ base := mustNew(t, model.HASH, nil)
+ base.Lock()
+ inc := mustNew(t, model.HASH, nil)
+ inc.Set("k", 1)
+ err := base.Merge(inc)
+ if err == nil {
+ t.Fatal("expected error from Merge on locked model")
+ }
+ if !errors.Is(err, model.ReadOnlyModel) {
+ t.Errorf("expected ReadOnlyModel, got %v", err)
+ }
+}
+
+func TestLockPreventsReverse(t *testing.T) {
+ m := mustNew(t, model.LIST, []any{1, 2, 3})
+ m.Lock()
+ err := m.Reverse()
+ if err == nil {
+ t.Fatal("expected error from Reverse on locked model")
+ }
+ if !errors.Is(err, model.ReadOnlyModel) {
+ t.Errorf("expected ReadOnlyModel, got %v", err)
+ }
+}
+
+func TestLockPreventsSetData(t *testing.T) {
+ m := mustNew(t, model.HASH, nil)
+ m.Lock()
+ err := m.SetData(map[string]any{"k": 1})
+ if err == nil {
+ t.Fatal("expected error from SetData on locked model")
+ }
+ if !errors.Is(err, model.ReadOnlyModel) {
+ t.Errorf("expected ReadOnlyModel, got %v", err)
+ }
+}
+
+// ── Additional concurrency ─────────────────────────────────────────────────────
+
+func TestConcurrentReadWrite(t *testing.T) {
+ m := mustNew(t, model.HASH, nil)
+ for i := 0; i < 10; i++ {
+ m.Set(fmt.Sprintf("k%d", i), i)
+ }
+ var wg sync.WaitGroup
+ for i := 0; i < 40; i++ {
+ wg.Add(1)
+ go func(i int) {
+ defer wg.Done()
+ if i%2 == 0 {
+ m.Get(fmt.Sprintf("k%d", i%10))
+ m.Has(fmt.Sprintf("k%d", i%10))
+ m.Len()
+ } else {
+ m.Set(fmt.Sprintf("extra%d", i), i)
+ }
+ }(i)
+ }
+ wg.Wait()
+ if m.Len() < 10 {
+ t.Errorf("expected at least 10 elements after concurrent access, got %d", m.Len())
+ }
+}
+
+func TestConcurrentReadersAreParallel(t *testing.T) {
+ // With RWMutex, multiple readers should not block each other.
+ // Load the model, then fire many goroutines reading simultaneously
+ // and verify all reads return correct values.
+ m := mustNew(t, model.HASH, nil)
+ for i := 0; i < 50; i++ {
+ m.Set(fmt.Sprintf("k%d", i), i)
+ }
+ var wg sync.WaitGroup
+ errs := make(chan string, 100)
+ for i := 0; i < 100; i++ {
+ wg.Add(1)
+ go func(idx int) {
+ defer wg.Done()
+ key := fmt.Sprintf("k%d", idx%50)
+ v, err := m.Get(key)
+ if err != nil {
+ errs <- fmt.Sprintf("Get(%s): %v", key, err)
+ return
+ }
+ n, _ := v.Int()
+ if n != idx%50 {
+ errs <- fmt.Sprintf("Get(%s): expected %d, got %d", key, idx%50, n)
+ }
+ }(i)
+ }
+ wg.Wait()
+ close(errs)
+ for e := range errs {
+ t.Error(e)
+ }
+}
+
+// ── GetData isolation ─────────────────────────────────────────────────────────
+
+func TestGetDataReturnsCopies(t *testing.T) {
+ m := mustNew(t, model.HASH, nil)
+ m.Set("a", 1)
+
+ data, hashIdx, idxHash := m.GetData()
+
+ // Mutate the returned copies — the model must be unaffected.
+ data[0] = "mutated"
+ hashIdx["injected"] = 99
+ idxHash[99] = "injected"
+
+ if m.Has("injected") {
+ t.Error("mutating the returned hashIdx should not affect the model")
+ }
+ v, err := m.Get("a")
+ if err != nil {
+ t.Fatalf("Get('a') after GetData mutation: %v", err)
+ }
+ if intVal(t, v) != 1 {
+ t.Errorf("model data was affected by mutation of GetData result; expected 1, got %d", intVal(t, v))
+ }
+}
+
+// ── Prev() cursor clamp regression tests ─────────────────────────────────────
+
+func TestPrevClampToMinusOne(t *testing.T) {
+ // Calling Prev() when cursor is already at -1 must not send pos below -1.
+ // Before the fix: pos would go to -2, -3, etc., causing Next() to panic
+ // with a negative slice index.
+ m := mustNew(t, model.LIST, []any{1, 2, 3})
+
+ // Prev on a fresh model (pos=-1)
+ var key, val any
+ if m.Prev(&key, &val) {
+ t.Error("Prev on fresh model should return false")
+ }
+ // Call Next after Prev — must NOT panic and must return the first element
+ if !m.Next(&key, &val) {
+ t.Fatal("Next after Prev-from-start should return true")
+ }
+ if intVal(t, val.(stdModel.Value)) != 1 {
+ t.Errorf("expected element 1, got %d", intVal(t, val.(stdModel.Value)))
+ }
+}
+
+func TestPrevMultipleCallsPastStart(t *testing.T) {
+ // Multiple Prev() calls past the start must not corrupt the cursor.
+ m := mustNew(t, model.LIST, []any{10, 20, 30})
+
+ // Call Prev many times from the beginning
+ var key, val any
+ for i := 0; i < 10; i++ {
+ m.Prev(&key, &val)
+ }
+ // Cursor must be clamped at -1; Next should restart from element 0
+ if !m.Next(&key, &val) {
+ t.Fatal("Next after repeated Prev should return true")
+ }
+ if intVal(t, val.(stdModel.Value)) != 10 {
+ t.Errorf("expected element 10, got %d", intVal(t, val.(stdModel.Value)))
+ }
+}
+
+// ── Delete / SetData cursor regression tests ──────────────────────────────────
+
+func TestDeleteFailedDoesNotResetPos(t *testing.T) {
+ // A failed Delete must not reset the cursor.
+ m := mustNew(t, model.LIST, []any{1, 2, 3})
+ var key, val any
+ m.Next(&key, &val) // pos = 0
+ m.Next(&key, &val) // pos = 1
+
+ // Delete with out-of-bounds index — should fail
+ if err := m.Delete(99); err == nil {
+ t.Fatal("expected error from Delete(99)")
+ }
+ // Cursor must still be at position 1
+ if !m.Cur(&key, &val) {
+ t.Fatal("Cur should still return true — cursor should be unchanged after failed Delete")
+ }
+ if intVal(t, val.(stdModel.Value)) != 2 {
+ t.Errorf("expected element 2 at current position, got %d", intVal(t, val.(stdModel.Value)))
+ }
+}
+
+func TestDeleteHashFailedDoesNotResetPos(t *testing.T) {
+ m := mustNew(t, model.HASH, nil)
+ m.Set("a", 1)
+ m.Set("b", 2)
+ m.Seek("a")
+
+ if err := m.Delete("nonexistent"); err == nil {
+ t.Fatal("expected error from Delete('nonexistent')")
+ }
+ // Cursor must still be at 'a'
+ var key, val any
+ if !m.Cur(&key, &val) {
+ t.Fatal("Cur should still return true after failed hash Delete")
+ }
+ if key.(string) != "a" {
+ t.Errorf("expected cursor at 'a', got %v", key)
+ }
+}
+
+func TestSetDataFailedDoesNotResetPos(t *testing.T) {
+ m := mustNew(t, model.LIST, []any{1, 2, 3})
+ var key, val any
+ m.Next(&key, &val) // pos = 0
+ m.Next(&key, &val) // pos = 1
+
+ // SetData with wrong type — should fail
+ if err := m.SetData("not-a-slice"); err == nil {
+ t.Fatal("expected error from SetData with wrong type")
+ }
+ // Cursor must still be at position 1
+ if !m.Cur(&key, &val) {
+ t.Fatal("Cur should still return true — cursor should be unchanged after failed SetData")
+ }
+ if intVal(t, val.(stdModel.Value)) != 2 {
+ t.Errorf("expected element 2, got %d", intVal(t, val.(stdModel.Value)))
+ }
+}
+
+// ── Sort no-op does not reset cursor ──────────────────────────────────────────
+
+func TestSortNoFlagsDoesNotResetPos(t *testing.T) {
+ m := mustNew(t, model.LIST, []any{1, 2, 3})
+ var key, val any
+ m.Next(&key, &val) // pos = 0
+
+ // Sort with zero flags — no-op, cursor must not be reset
+ m.Sort(0)
+ if !m.Cur(&key, &val) {
+ t.Error("Cur should still return true after Sort(0)")
+ }
+ if intVal(t, val.(stdModel.Value)) != 1 {
+ t.Errorf("expected element 1 after Sort(0), got %d", intVal(t, val.(stdModel.Value)))
+ }
+}
+
+func TestSortByKeyOnListNoOpDoesNotResetPos(t *testing.T) {
+ m := mustNew(t, model.LIST, []any{1, 2, 3})
+ var key, val any
+ m.Next(&key, &val) // pos = 0
+
+ // SortByKey on list is a no-op (no SortAsString), cursor must not be reset
+ m.Sort(stdSorter.SortByKey)
+ if !m.Cur(&key, &val) {
+ t.Error("Cur should still return true after SortByKey no-op on list")
+ }
+}
+
+// ── Cursor reset after mutation ───────────────────────────────────────────────
+
+func TestSetDataResetsPos(t *testing.T) {
+ m := mustNew(t, model.LIST, []any{1, 2, 3})
+ // advance cursor to position 1
+ var key, val any
+ m.Next(&key, &val)
+ m.Next(&key, &val)
+ // replace data — cursor must reset
+ m.SetData([]any{10, 20})
+ // Cur must return false (pos == -1)
+ if m.Cur(&key, &val) {
+ t.Error("Cur should return false after SetData resets the cursor")
+ }
+ // Next must restart from the beginning
+ if !m.Next(&key, &val) {
+ t.Fatal("Next returned false after SetData")
+ }
+ if intVal(t, val.(stdModel.Value)) != 10 {
+ t.Errorf("expected first element 10 after SetData, got %d", intVal(t, val.(stdModel.Value)))
+ }
+}
+
+func TestDeleteResetsPos(t *testing.T) {
+ m := mustNew(t, model.LIST, []any{1, 2, 3})
+ var key, val any
+ m.Next(&key, &val) // pos = 0
+ m.Next(&key, &val) // pos = 1
+ m.Delete(0)
+ // cursor must have been reset
+ if m.Cur(&key, &val) {
+ t.Error("Cur should return false after Delete resets the cursor")
+ }
+}
+
+func TestSortResetsPos(t *testing.T) {
+ m := mustNew(t, model.LIST, []any{3, 1, 2})
+ var key, val any
+ m.Next(&key, &val) // pos = 0
+ m.Sort(stdSorter.SortAsc)
+ // cursor must have been reset
+ if m.Cur(&key, &val) {
+ t.Error("Cur should return false after Sort resets the cursor")
+ }
+ // Next should start from the new beginning
+ if !m.Next(&key, &val) {
+ t.Fatal("Next returned false after Sort")
+ }
+ if intVal(t, val.(stdModel.Value)) != 1 {
+ t.Errorf("expected sorted first element 1 after Sort, got %d", intVal(t, val.(stdModel.Value)))
+ }
+}
+
+func TestReverseResetsPos(t *testing.T) {
+ m := mustNew(t, model.LIST, []any{1, 2, 3})
+ var key, val any
+ m.Next(&key, &val) // pos = 0
+ m.Next(&key, &val) // pos = 1
+ m.Reverse()
+ if m.Cur(&key, &val) {
+ t.Error("Cur should return false after Reverse resets the cursor")
+ }
+}
+
+// ── UnmarshalJSON atomicity ───────────────────────────────────────────────────
+
+// TestUnmarshalJSONNeverEmpty verifies that concurrent readers never observe
+// the model in an empty state during UnmarshalJSON. With the old two-phase
+// reset+import approach, readers could see Len()==0 briefly. The temp-model
+// swap eliminates that window.
+func TestUnmarshalJSONNeverEmpty(t *testing.T) {
+ m := mustNew(t, model.HASH, nil)
+ for i := 0; i < 50; i++ {
+ m.Set(fmt.Sprintf("k%d", i), i)
+ }
+
+ sawEmpty := int32(0)
+ var wg sync.WaitGroup
+
+ // Concurrent readers
+ for i := 0; i < 20; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for j := 0; j < 200; j++ {
+ if m.Len() == 0 {
+ atomic.StoreInt32(&sawEmpty, 1)
+ }
+ }
+ }()
+ }
+
+ // Concurrent unmarshalers replacing data
+ newData := []byte(`{"a":1,"b":2,"c":3,"d":4,"e":5}`)
+ for i := 0; i < 5; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for j := 0; j < 20; j++ {
+ json.Unmarshal(newData, m)
+ }
+ }()
+ }
+
+ wg.Wait()
+ if atomic.LoadInt32(&sawEmpty) == 1 {
+ t.Error("concurrent reader observed model in empty state during UnmarshalJSON")
+ }
+}
+
+// ── TOCTOU: locked check before vs inside the write lock ──────────────────────
+
+// TestLockTOCTOUDoubleCheck verifies that after Lock() is called, no write
+// method can succeed — even writes that checked locked before Lock() was
+// called. The double-check inside the mutex closes this window.
+func TestLockTOCTOUDoubleCheck(t *testing.T) {
+ for attempt := 0; attempt < 50; attempt++ {
+ m := mustNew(t, model.HASH, nil)
+
+ var wg sync.WaitGroup
+ wg.Add(2)
+
+ // Goroutine A: hammers Set
+ go func() {
+ defer wg.Done()
+ for i := 0; i < 500; i++ {
+ m.Set("k", i)
+ }
+ }()
+
+ // Goroutine B: calls Lock() after a brief yield
+ go func() {
+ defer wg.Done()
+ runtime.Gosched()
+ m.Lock()
+ }()
+
+ wg.Wait()
+
+ // After both goroutines finish, Lock() has definitely been called.
+ // All subsequent writes MUST fail.
+ for i := 0; i < 20; i++ {
+ if err := m.Set("k", i); err == nil {
+ t.Errorf("attempt %d: Set succeeded after Lock() was called", attempt)
+ }
+ if err := m.Push("v"); err == nil {
+ t.Errorf("attempt %d: Push succeeded after Lock() was called", attempt)
+ }
+ if err := m.Delete("k"); err == nil {
+ t.Errorf("attempt %d: Delete succeeded after Lock() was called", attempt)
+ }
+ }
+ }
+}
+
+// TestLockIsAtomic verifies that the model's locked state is observed
+// consistently by all goroutines immediately after Lock() returns.
+func TestLockIsAtomic(t *testing.T) {
+ m := mustNew(t, model.HASH, nil)
+ m.Lock()
+
+ var wg sync.WaitGroup
+ failures := int32(0)
+ for i := 0; i < 100; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ if err := m.Set("k", 1); err == nil {
+ atomic.AddInt32(&failures, 1)
+ }
+ }()
+ }
+ wg.Wait()
+ if failures > 0 {
+ t.Errorf("%d goroutines succeeded writing to a locked model", failures)
+ }
+}
+
+// ── Concurrent Merge safety ───────────────────────────────────────────────────
+
+// TestConcurrentMergeReadsDoNotPanic verifies that concurrent reads during a
+// Merge (including during the recursive nested-model unlock window) do not
+// panic or corrupt the model.
+func TestConcurrentMergeReadsDoNotPanic(t *testing.T) {
+ // Build a model with deeply nested sub-models to exercise the unlock window
+ inner1, _ := model.New(model.HASH, nil)
+ for i := 0; i < 10; i++ {
+ inner1.Set(fmt.Sprintf("x%d", i), i)
+ }
+ base, _ := model.New(model.HASH, nil)
+ base.Set("nested", inner1)
+ for i := 0; i < 10; i++ {
+ base.Set(fmt.Sprintf("k%d", i), i)
+ }
+
+ inner2, _ := model.New(model.HASH, nil)
+ for i := 0; i < 10; i++ {
+ inner2.Set(fmt.Sprintf("y%d", i), i*10)
+ }
+ incoming, _ := model.New(model.HASH, nil)
+ incoming.Set("nested", inner2)
+ for i := 0; i < 5; i++ {
+ incoming.Set(fmt.Sprintf("new%d", i), i*100)
+ }
+
+ var wg sync.WaitGroup
+
+ // Concurrent readers during Merge
+ for i := 0; i < 20; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for j := 0; j < 100; j++ {
+ base.Len()
+ base.Has("nested")
+ base.Get("k0")
+ }
+ }()
+ }
+
+ // The merge itself
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ if err := base.Merge(incoming); err != nil {
+ t.Errorf("Merge failed: %v", err)
+ }
+ }()
+
+ wg.Wait()
+ // Verify model is still consistent after concurrent access
+ if base.Len() == 0 {
+ t.Error("model is empty after concurrent Merge+reads")
+ }
+}
+
+// ── Has key coercion ──────────────────────────────────────────────────────────
+
+// TestHasHashKeyCoercion verifies that Has uses the same key coercion as Set/Get/Delete.
+// Before the fix, Has used a direct string type assertion so Has(42) returned false even
+// when key "42" existed (set via Set(42, "v")).
+func TestHasHashKeyCoercion(t *testing.T) {
+ m := mustNew(t, model.HASH, nil)
+ m.Set(42, "forty-two")
+ m.Set(3.14, "pi")
+
+ if !m.Has("42") {
+ t.Error("Has('42') should be true after Set(42, ...)")
+ }
+ if !m.Has(42) {
+ t.Error("Has(42) should be true — same coercion as Set/Get/Delete")
+ }
+ if !m.Has(3.14) {
+ t.Error("Has(3.14) should be true — same coercion as Set/Get/Delete")
+ }
+}
+
+// ── SetData LIST slice isolation ──────────────────────────────────────────────
+
+// TestSetDataListDoesNotAliasInputSlice verifies that SetData copies the input
+// slice so that external mutations do not corrupt the model's internal state.
+func TestSetDataListDoesNotAliasInputSlice(t *testing.T) {
+ d := []any{1, 2, 3}
+ m := mustNew(t, model.LIST, nil)
+ if err := m.SetData(d); err != nil {
+ t.Fatalf("SetData: %v", err)
+ }
+
+ // Mutate the original slice after SetData.
+ d[0] = 99
+
+ v, err := m.Get(0)
+ if err != nil {
+ t.Fatalf("Get(0): %v", err)
+ }
+ if intVal(t, v) != 1 {
+ t.Errorf("SetData aliased input slice: expected 1 at index 0, got %d", intVal(t, v))
+ }
+}
+
+// ── Merge TOCTOU: Lock during recursive sub-model merge ───────────────────────
+
+// TestMergeLockDuringRecursiveUnlockWindow verifies that if Lock() is called on
+// the outer model during the recursive sub-model merge (which releases and
+// re-acquires the outer mutex), subsequent keys in the outer merge are NOT
+// written. Before the fix, the re-check after relocking was missing.
+func TestMergeLockDuringRecursiveUnlockWindow(t *testing.T) {
+ inner1, _ := model.New(model.HASH, nil)
+ inner1.Set("a", 1)
+ base, _ := model.New(model.HASH, nil)
+ base.Set("nested", inner1)
+ base.Set("plain", 10)
+
+ inner2, _ := model.New(model.HASH, nil)
+ inner2.Set("b", 2)
+ incoming, _ := model.New(model.HASH, nil)
+ incoming.Set("nested", inner2)
+ incoming.Set("extra", 99) // key that would be written after the recursive merge
+
+ // Lock the base model. Merge should detect this and return an error rather
+ // than writing "extra" to a locked model.
+ base.Lock()
+
+ err := base.Merge(incoming)
+ if err == nil {
+ t.Fatal("Merge on locked model should return an error")
+ }
+ if !errors.Is(err, model.ReadOnlyModel) {
+ t.Errorf("expected ReadOnlyModel, got %v", err)
+ }
+ // Confirm "extra" was not written.
+ if base.Has("extra") {
+ t.Error("Merge wrote 'extra' to a locked model")
+ }
+}
+
+// waitTimeout returns a channel that fires after d, used to detect deadlocks in tests.
+func waitTimeout(d time.Duration) <-chan struct{} {
+ ch := make(chan struct{})
+ go func() {
+ time.Sleep(d)
+ close(ch)
+ }()
+ return ch
}
diff --git a/value.go b/value.go
index a26e905..d6f9e1d 100644
--- a/value.go
+++ b/value.go
@@ -1,68 +1,106 @@
package model
import (
+ "encoding/json"
+
"github.com/bdlm/cast/v2"
"github.com/bdlm/errors/v2"
stdModel "github.com/bdlm/std/v2/model"
)
-// Value implements github.com/bdlm/std/Value.
+// Value is the element type stored inside a [Model]. It wraps an arbitrary Go
+// value and exposes typed accessors that convert the underlying data using the
+// bdlm/cast package.
+//
+// Value implements the github.com/bdlm/std/v2/model.Value interface.
+// Conversion methods return a non-nil error when the underlying data cannot be
+// represented in the target type.
+//
+// The data field is intentionally unexported. All access to the raw underlying
+// value goes through [Value.Value] or one of the typed conversion methods.
+// Values are obtained from a Model via [Model.Get] or the iterator methods;
+// they should not be constructed directly by callers.
type Value struct {
data any
}
-// Bool returns the boolean representation of the value of this node, or an
-// error if the type conversion is not possible.
+// To converts val to the target type TTo using the bdlm/cast package. On
+// conversion failure the zero value of TTo is returned. For an error-aware
+// conversion use [ToE].
+//
+// TTo is constrained to the set of types supported by cast.Types.
+func To[TTo cast.Types](val any, ops ...cast.Op) TTo {
+ return cast.To[TTo](val, ops...)
+}
+
+// ToE converts val to the target type TTo using the bdlm/cast package,
+// returning the converted value and a non-nil error if the conversion fails
+// or would be lossy.
+//
+// TTo is constrained to the set of types supported by cast.Types.
+func ToE[TTo cast.Types](val any, ops ...cast.Op) (TTo, error) {
+ return cast.ToE[TTo](val, ops...)
+}
+
+// MarshalJSON implements json.Marshaler. It marshals the underlying data
+// directly so that a *Value stored inside a Model serializes as its content
+// rather than as a struct literal with an unexported field.
+func (val *Value) MarshalJSON() ([]byte, error) {
+ return json.Marshal(val.data)
+}
+
+// Bool returns the boolean representation of the underlying value. Conversion
+// follows the bdlm/cast rules: numeric zero is false, any non-zero number is
+// true; strings are parsed according to strconv.ParseBool. Returns a non-nil
+// error if the conversion is not possible.
func (val *Value) Bool() (bool, error) {
- result, err := cast.ToE[bool](val.data)
+ result, err := ToE[bool](val.data)
if nil != err {
err = errors.Wrap(err, "could not convert value '%v' to a boolean", val.data)
}
return result, err
}
-// Float returns the float64 representation of the value of this node, or an
-// error if the type conversion is not possible.
+// Float is an alias for [Value.Float64].
func (val *Value) Float() (float64, error) {
- result, err := cast.ToE[float64](val.data)
- if nil != err {
- err = errors.Wrap(err, "could not convert value '%v' to a float64", val.data)
- }
- return result, err
+ return val.Float64()
}
-// Float32 returns the float32 representation of the value of this node, or an
-// error if the type conversion is not possible.
+// Float32 returns the float32 representation of the underlying value. Returns
+// a non-nil error if the conversion is not possible.
func (val *Value) Float32() (float32, error) {
- result, err := cast.ToE[float32](val.data)
+ result, err := ToE[float32](val.data)
if nil != err {
err = errors.Wrap(err, "could not convert value '%v' to a float32", val.data)
}
return result, err
}
-// Float64 returns the float64 representation of the value of this node, or an
-// error if the type conversion is not possible.
+// Float64 returns the float64 representation of the underlying value. Returns
+// a non-nil error if the conversion is not possible.
func (val *Value) Float64() (float64, error) {
- result, err := cast.ToE[float64](val.data)
+ result, err := ToE[float64](val.data)
if nil != err {
err = errors.Wrap(err, "could not convert value '%v' to a float64", val.data)
}
return result, err
}
-// Int returns the int representation of the value of this node, or an error if
-// the type conversion is not possible.
+// Int returns the int representation of the underlying value. Floating-point
+// values are truncated toward zero. Returns a non-nil error if the conversion
+// is not possible (for example, a non-numeric string).
func (val *Value) Int() (int, error) {
- result, err := cast.ToE[int](val.data)
+ result, err := ToE[int](val.data)
if nil != err {
err = errors.Wrap(err, "could not convert value '%v' to an int", val.data)
}
return result, err
}
-// List returns the array of Values stored in this node, or an error if the
-// type conversion is not possible.
+// List returns the underlying value as a []stdModel.Value. This succeeds only
+// when the stored data is exactly a []stdModel.Value — it is not a general
+// accessor for nested LIST models. For a nested LIST model stored inside a
+// Model, retrieve the Value with [Model.Get] and call [Value.Model] instead.
func (val *Value) List() ([]stdModel.Value, error) {
var err error
result, ok := val.data.([]stdModel.Value)
@@ -72,8 +110,11 @@ func (val *Value) List() ([]stdModel.Value, error) {
return result, err
}
-// Map returns the map[string]Value data stored in this node, or an error if
-// the type conversion is not possible.
+// Map returns the underlying value as a map[string]stdModel.Value. This
+// succeeds only when the stored data is exactly a map[string]stdModel.Value —
+// it is not a general accessor for nested HASH models. For a nested HASH model
+// stored inside a Model, retrieve the Value with [Model.Get] and call
+// [Value.Model] instead.
func (val *Value) Map() (map[string]stdModel.Value, error) {
var err error
result, ok := val.data.(map[string]stdModel.Value)
@@ -83,8 +124,10 @@ func (val *Value) Map() (map[string]stdModel.Value, error) {
return result, err
}
-// Model returns the Model stored at this node, or an error if the value does
-// not implement Model.
+// Model returns the underlying value as a stdModel.Model. This succeeds when
+// the stored data implements the stdModel.Model interface, which is true for
+// any *[Model] stored via [Model.Set] or [Model.Push] and for nested models
+// created automatically during JSON import.
func (val *Value) Model() (stdModel.Model, error) {
var err error
result, ok := val.data.(stdModel.Model)
@@ -94,17 +137,20 @@ func (val *Value) Model() (stdModel.Model, error) {
return result, err
}
-// String returns the string representation of the value, or an error if the
-// type conversion is not possible.
+// String returns the string representation of the underlying value using the
+// bdlm/cast package. Numeric values are formatted as their decimal string
+// equivalents. Returns a non-nil error if the conversion is not possible.
func (val *Value) String() (string, error) {
- result, err := cast.ToE[string](val.data)
+ result, err := ToE[string](val.data)
if nil != err {
err = errors.Wrap(err, "could not convert value '%v' to a string", val.data)
}
return result, err
}
-// Value returns the untyped value.
+// Value returns the raw underlying data without any type conversion. The
+// returned value is the same any that was originally passed to [Model.Set] or
+// [Model.Push], or that was decoded from JSON.
func (val *Value) Value() any {
return val.data
}
From e0321c25269936ba12643afa0f84bc922f950a2a Mon Sep 17 00:00:00 2001
From: Michael Kenney
Date: Sat, 23 May 2026 03:27:31 -0600
Subject: [PATCH 3/4] readme
---
README.md | 2 ++
1 file changed, 2 insertions(+)
diff --git a/README.md b/README.md
index 6b092ef..3a2fb88 100644
--- a/README.md
+++ b/README.md
@@ -14,6 +14,8 @@
+
Code is fairly settled and is in use in production systems. Backwards-compatibility will be mintained unless serious issues are discovered and a better solution is reached.
+
`bdlm/model` is a generic, type-agnostic data container for Go. A single `Model` can hold either a **hash** (string-keyed map) or a **list** (integer-indexed array) of arbitrary values, with full support for nested models, bidirectional cursor iteration, sorting, merging, functional transforms, and JSON marshaling.
All public methods are safe for concurrent use.
From 1c3f2ebdc2b5a00b7a4658dc729a2c8e1d6953b0 Mon Sep 17 00:00:00 2001
From: Michael Kenney
Date: Sat, 23 May 2026 04:25:56 -0600
Subject: [PATCH 4/4] updates
---
.github/dependabot.yml | 11 ++
.github/workflows/go.yml | 35 ++++++
.gitignore | 1 -
README.md | 4 +-
examples_test.go | 5 +-
go.mod | 12 +-
go.sum | 68 ++++++++++
iface_check_test.go | 16 +++
interface.marshaler.go | 3 +-
model.go | 23 ++--
model_test.go | 260 +++++++++++++++++++++++++++++++++++++--
11 files changed, 401 insertions(+), 37 deletions(-)
create mode 100644 .github/dependabot.yml
create mode 100644 .github/workflows/go.yml
create mode 100644 go.sum
create mode 100644 iface_check_test.go
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..cd88554
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,11 @@
+# To get started with Dependabot version updates, you'll need to specify which
+# package ecosystems to update and where the package manifests are located.
+# Please see the documentation for all configuration options:
+# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
+
+version: 2
+updates:
+ - package-ecosystem: "gomod" # See documentation for possible values
+ directory: "/" # Location of package manifests
+ schedule:
+ interval: "weekly"
diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml
new file mode 100644
index 0000000..b402d5f
--- /dev/null
+++ b/.github/workflows/go.yml
@@ -0,0 +1,35 @@
+# This workflow will build a golang project
+# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go
+
+name: Go
+
+on:
+ push:
+ branches:
+ - main
+ - 'v[0-9]+.[0-9]+.[0-9]+*'
+ pull_request:
+ branches:
+ - main
+ - 'v[0-9]+.[0-9]+.[0-9]+*'
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ go-version: ['1.21', '1.22', '1.23', '1.24', '1.25', '1.26']
+
+ steps:
+ - uses: actions/checkout@v3
+
+ - name: Set up Go ${{ matrix.go-version }}
+ uses: actions/setup-go@v4
+ with:
+ go-version: ${{ matrix.go-version }}
+
+ - name: Build
+ run: go build -v ./...
+
+ - name: Test
+ run: go test -race -v ./...
diff --git a/.gitignore b/.gitignore
index 9d18a33..af73404 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,5 @@
/vendor
vendor/
-go.sum
go.work
go.work.sum
benchmark*
diff --git a/README.md b/README.md
index 3a2fb88..0e9ff65 100644
--- a/README.md
+++ b/README.md
@@ -6,12 +6,12 @@
-
+
-
+
Code is fairly settled and is in use in production systems. Backwards-compatibility will be mintained unless serious issues are discovered and a better solution is reached.
diff --git a/examples_test.go b/examples_test.go
index ccd267e..f932b79 100644
--- a/examples_test.go
+++ b/examples_test.go
@@ -271,13 +271,12 @@ func ExampleModel_SetData() {
}
// ExampleModel_SetData_hash demonstrates replacing all data in a HASH model.
-// The map iteration order is non-deterministic, so Sort is called to ensure
-// a consistent result.
+// Keys are stored in ascending alphabetical order, matching the behaviour of
+// New and UnmarshalJSON.
func ExampleModel_SetData_hash() {
m, _ := model.New(model.HASH, nil)
m.Set("old", 99)
m.SetData(map[string]any{"a": 1, "b": 2})
- m.Sort(sorter.SortByKey) // normalize order
var k, v any
for m.Next(&k, &v) {
n, _ := v.(stdModel.Value).Int()
diff --git a/go.mod b/go.mod
index 0a350cb..e127f14 100644
--- a/go.mod
+++ b/go.mod
@@ -1,17 +1,11 @@
module github.com/bdlm/model
-go 1.26.2
+go 1.21
require (
github.com/bdlm/cast/v2 v2.1.4
github.com/bdlm/errors/v2 v2.1.2
- github.com/bdlm/log/v2 v2.0.7
- github.com/bdlm/std/v2 v2.1.1-rc3
+ github.com/bdlm/std/v2 v2.2.0
)
-require (
- github.com/stretchr/testify v1.10.0 // indirect
- golang.org/x/crypto v0.21.0 // indirect
- golang.org/x/sys v0.18.0 // indirect
- golang.org/x/term v0.18.0 // indirect
-)
+require github.com/stretchr/testify v1.10.0 // indirect
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..b3838c5
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,68 @@
+cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
+github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
+github.com/bdlm/cast/v2 v2.1.4 h1:Ap3LxP1sbtpqrf/jk/o6/oIOfidLTs1FvK9n8oDK2BU=
+github.com/bdlm/cast/v2 v2.1.4/go.mod h1:gkWzzX34aQpgxivV/d1q/mt+Fwybg5wFkY0Ej6wfIYw=
+github.com/bdlm/errors/v2 v2.1.2 h1:fWv7r5V6uhZVjJYE55UR+CRfmww1DMvA0vfAPifHmV0=
+github.com/bdlm/errors/v2 v2.1.2/go.mod h1:bgBov2jFI+IW4NV/ZmHlLYVZCYw0e3nH+p2ReQ2UwBc=
+github.com/bdlm/std/v2 v2.1.0/go.mod h1:E46ljWlCLyBIp7uHLGPKcy6W6go0e7srmZblzQKRGho=
+github.com/bdlm/std/v2 v2.2.0 h1:9NkQySkj6LAajTe455kvIl9JFvtBU5QyQWM/A6wrw/A=
+github.com/bdlm/std/v2 v2.2.0/go.mod h1:E46ljWlCLyBIp7uHLGPKcy6W6go0e7srmZblzQKRGho=
+github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
+github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
+github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
+github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
+github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
+github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
+github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
+github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/golang/protobuf v1.3.3 h1:gyjaxf+svBWX08ZjK86iN9geUJF0H6gp2IRKX6Nf6/I=
+github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
+github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
+github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
+github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
+golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
+golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
+golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
+golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
+golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
+golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
+golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
+golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
+google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
+google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
+google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
+google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55 h1:gSJIx1SDwno+2ElGhA4+qG2zF97qiUzTM+rQ0klBOcE=
+google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
+google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
+google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
+google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
+google.golang.org/grpc v1.29.1 h1:EC2SB8S04d2r73uptxphDSUG+kTKVgjRPF+N3xpxRB4=
+google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
+honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
diff --git a/iface_check_test.go b/iface_check_test.go
new file mode 100644
index 0000000..076f419
--- /dev/null
+++ b/iface_check_test.go
@@ -0,0 +1,16 @@
+package model_test
+
+import (
+ "github.com/bdlm/model"
+ stdIterator "github.com/bdlm/std/v2/iterator"
+ stdModel "github.com/bdlm/std/v2/model"
+ stdSorter "github.com/bdlm/std/v2/sorter"
+)
+
+// Compile-time assertions that *Model and *Value satisfy the intended interfaces.
+var (
+ _ stdModel.Model = (*model.Model)(nil)
+ _ stdModel.Value = (*model.Value)(nil)
+ _ stdSorter.Sorter = (*model.Model)(nil)
+ _ stdIterator.Iterator = (*model.Model)(nil)
+)
diff --git a/interface.marshaler.go b/interface.marshaler.go
index 1b857a5..f1b6ebf 100644
--- a/interface.marshaler.go
+++ b/interface.marshaler.go
@@ -8,8 +8,7 @@ type Marshaler interface {
// Unmarshaler is the interface implemented by Models that can unmarshal a
// serialized description of themselves. The input can be assumed to be a valid
-// encoding of a Model value. UnmarshalModel must copy the data if it wishes to
-// retain the data after returning.
+// encoding of a Model value.
//
// By convention, to approximate the behavior of similar functionality in other
// packages, Unmarshalers implement UnmarshalModel([]byte("null")) as a no-op.
diff --git a/model.go b/model.go
index 603a5fb..9a9fe58 100644
--- a/model.go
+++ b/model.go
@@ -1,6 +1,7 @@
package model
import (
+ "sort"
"sync"
"sync/atomic"
@@ -639,9 +640,9 @@ func (mdl *Model) SetID(id any) error {
// subsequent mutations to the original slice do not affect the model.
//
// For HASH models, data must be map[string]any. The key-value pairs are
-// inserted into a fresh data store. Because Go map iteration order is
-// non-deterministic, call [Model.Sort] afterward if a specific element order
-// is required. Values are stored without wrapping in *[Value]; they are wrapped
+// inserted into a fresh data store in ascending alphabetical key order,
+// matching the deterministic ordering applied by [Model.UnmarshalJSON] and
+// [importMap]. Values are stored without wrapping in *[Value]; they are wrapped
// transparently on retrieval by [Model.Get] and the iterator methods.
//
// A successful SetData resets the cursor to -1. A failed call (wrong type for
@@ -673,14 +674,20 @@ func (mdl *Model) SetData(data any) error {
return errors.WrapE(InvalidDataSet, errors.Errorf("invalid data set for hash model"))
}
+ keys := make([]string, 0, len(d))
+ for k := range d {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+
mdl.pos = -1 // successful mutation invalidates cursor
- mdl.data = []any{}
- mdl.hashIdx = map[string]int{}
- mdl.idxHash = map[int]string{}
- for k, v := range d {
+ mdl.data = make([]any, 0, len(d))
+ mdl.hashIdx = make(map[string]int, len(d))
+ mdl.idxHash = make(map[int]string, len(d))
+ for _, k := range keys {
mdl.hashIdx[k] = len(mdl.data)
mdl.idxHash[len(mdl.data)] = k
- mdl.data = append(mdl.data, v)
+ mdl.data = append(mdl.data, d[k])
}
return nil
}
diff --git a/model_test.go b/model_test.go
index f5c3f55..a581929 100644
--- a/model_test.go
+++ b/model_test.go
@@ -4,29 +4,17 @@ import (
"encoding/json"
"errors"
"fmt"
- "os"
"runtime"
"sync"
"sync/atomic"
"testing"
"time"
- "github.com/bdlm/log/v2"
"github.com/bdlm/model"
stdModel "github.com/bdlm/std/v2/model"
stdSorter "github.com/bdlm/std/v2/sorter"
)
-func init() {
- level, _ := log.ParseLevel("debug")
- if os.Getenv("SERVER_ENV") == "dev" {
- log.SetFormatter(&log.TextFormatter{ForceTTY: true, EnableTrace: false})
- } else {
- log.SetFormatter(&log.JSONFormatter{FieldMap: log.FieldMap{"data": "_"}})
- }
- log.SetLevel(level)
-}
-
// helpers
func mustNew(t *testing.T, typ stdModel.ModelType, data any) *model.Model {
@@ -563,6 +551,38 @@ func TestSetDataHash(t *testing.T) {
}
}
+// TestSetDataHashSortedOrder verifies that SetData on a HASH model inserts keys
+// in ascending alphabetical order, matching the behaviour of importMap and
+// UnmarshalJSON.
+func TestSetDataHashSortedOrder(t *testing.T) {
+ m := mustNew(t, model.HASH, nil)
+ if err := m.SetData(map[string]any{"c": 3, "a": 1, "b": 2}); err != nil {
+ t.Fatalf("SetData: %v", err)
+ }
+ want := []string{"a", "b", "c"}
+ for i, wantKey := range want {
+ v, err := m.Get(wantKey)
+ if err != nil {
+ t.Fatalf("Get(%q): %v", wantKey, err)
+ }
+ n, _ := v.Int()
+ if n != i+1 {
+ t.Errorf("key %q: want value %d, got %d", wantKey, i+1, n)
+ }
+ }
+ // Iterate to confirm key order.
+ var gotKeys []string
+ var k, v any
+ for m.Next(&k, &v) {
+ gotKeys = append(gotKeys, k.(string))
+ }
+ for i, k := range gotKeys {
+ if k != want[i] {
+ t.Errorf("iteration position %d: want key %q, got %q", i, want[i], k)
+ }
+ }
+}
+
func TestSetDataList(t *testing.T) {
m := mustNew(t, model.LIST, []any{1, 2, 3})
if err := m.SetData([]any{10, 20}); err != nil {
@@ -1399,6 +1419,23 @@ func TestMarshalModel(t *testing.T) {
}
}
+// TestModelStringErrorFallback verifies that Model.String() returns the error
+// message when MarshalJSON fails (e.g., when the model contains a value that
+// encoding/json cannot serialize, such as a channel).
+func TestModelStringErrorFallback(t *testing.T) {
+ m := mustNew(t, model.HASH, nil)
+ // Channels are not JSON-serializable; this causes MarshalJSON to return an error.
+ m.Set("bad", make(chan int))
+ s := m.String()
+ if s == "" {
+ t.Fatal("String() returned empty string; expected an error message")
+ }
+ // The string must not be valid JSON — it should be an error message, not "{}".
+ if s == "{}" || s[0] == '{' {
+ t.Errorf("String() returned JSON-like output %q; expected error message", s)
+ }
+}
+
func TestUnmarshalModel(t *testing.T) {
m := mustNew(t, model.HASH, nil)
m.Set("x", 99)
@@ -2186,6 +2223,205 @@ func TestMergeLockDuringRecursiveUnlockWindow(t *testing.T) {
}
}
+// TestSortByValueModelBucket verifies that stratifiedLess correctly sorts nested
+// *Model values in a LIST using the model bucket (bucket 0). Models are compared
+// by GetID() string and then by element count as a tiebreaker.
+func TestSortByValueModelBucket(t *testing.T) {
+ // Build three child models with distinct IDs.
+ mC, _ := model.New(model.HASH, nil)
+ mC.SetID("c")
+ mC.Set("x", 1)
+
+ mA, _ := model.New(model.HASH, nil)
+ mA.SetID("a")
+ mA.Set("x", 1)
+ mA.Set("y", 2)
+
+ mB, _ := model.New(model.HASH, nil)
+ mB.SetID("b")
+
+ // Insert in reverse-alphabetical order: c, a, b.
+ parent := mustNew(t, model.LIST, nil)
+ parent.Push(mC)
+ parent.Push(mA)
+ parent.Push(mB)
+
+ if err := parent.Sort(stdSorter.SortAsc); err != nil {
+ t.Fatalf("Sort: %v", err)
+ }
+
+ // After ascending sort the order should be: mA ("a") < mB ("b") < mC ("c").
+ for i, wantID := range []string{"a", "b", "c"} {
+ v, err := parent.Get(i)
+ if err != nil {
+ t.Fatalf("Get(%d): %v", i, err)
+ }
+ mdl, err := v.Model()
+ if err != nil {
+ t.Fatalf("Model() at index %d: %v", i, err)
+ }
+ gotID := fmt.Sprintf("%v", mdl.GetID())
+ if gotID != wantID {
+ t.Errorf("index %d: want ID %q, got %q", i, wantID, gotID)
+ }
+ }
+}
+
+// TestSortByValueModelTiebreakByLen verifies that when two *Model values share
+// the same GetID, the shorter model sorts before the longer one (modelLen tiebreak).
+func TestSortByValueModelTiebreakByLen(t *testing.T) {
+ mFew, _ := model.New(model.HASH, nil)
+ mFew.SetID("same")
+ mFew.Set("a", 1)
+
+ mMany, _ := model.New(model.HASH, nil)
+ mMany.SetID("same")
+ mMany.Set("a", 1)
+ mMany.Set("b", 2)
+ mMany.Set("c", 3)
+
+ parent := mustNew(t, model.LIST, nil)
+ parent.Push(mMany)
+ parent.Push(mFew)
+
+ if err := parent.Sort(stdSorter.SortAsc); err != nil {
+ t.Fatalf("Sort: %v", err)
+ }
+
+ v0, _ := parent.Get(0)
+ m0, _ := v0.Model()
+ data0, _, _ := m0.GetData()
+ if len(data0) != 1 {
+ t.Errorf("expected shorter model (len 1) at index 0, got len %d", len(data0))
+ }
+}
+
+// TestImportNestedSliceInSlice verifies that importSlice correctly handles
+// arrays-of-arrays by creating nested LIST child models.
+func TestImportNestedSliceInSlice(t *testing.T) {
+ jsn := []byte(`[[1,2],[3,4]]`)
+ m, err := model.New(model.LIST, nil)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ if err := m.UnmarshalJSON(jsn); err != nil {
+ t.Fatalf("UnmarshalJSON: %v", err)
+ }
+ if m.Len() != 2 {
+ t.Fatalf("expected 2 child models, got %d", m.Len())
+ }
+ // Each child should be a LIST model containing 2 numeric elements.
+ for i := 0; i < 2; i++ {
+ v, err := m.Get(i)
+ if err != nil {
+ t.Fatalf("Get(%d): %v", i, err)
+ }
+ child, err := v.Model()
+ if err != nil {
+ t.Fatalf("Model() at index %d: %v", i, err)
+ }
+ childData, _, _ := child.GetData()
+ if len(childData) != 2 {
+ t.Errorf("child[%d]: expected len 2, got %d", i, len(childData))
+ }
+ }
+ // Verify specific values: child[0][0]==1, child[0][1]==2, child[1][0]==3, child[1][1]==4.
+ expected := [][]float64{{1, 2}, {3, 4}}
+ for i, row := range expected {
+ cv, _ := m.Get(i)
+ child, _ := cv.Model()
+ for j, want := range row {
+ ev, err := child.Get(j)
+ if err != nil {
+ t.Fatalf("child[%d].Get(%d): %v", i, j, err)
+ }
+ got, err := ev.Float64()
+ if err != nil {
+ t.Fatalf("child[%d][%d] Float64: %v", i, j, err)
+ }
+ if got != want {
+ t.Errorf("child[%d][%d]: want %v, got %v", i, j, want, got)
+ }
+ }
+ }
+}
+
+// ── Benchmarks ─────────────────────────────────────────────────────────────────
+
+// BenchmarkSortByKeyHash measures Sort(SortByKey) on a HASH model with 100 entries.
+func BenchmarkSortByKeyHash(b *testing.B) {
+ m, _ := model.New(model.HASH, nil)
+ for i := 0; i < 100; i++ {
+ m.Set(fmt.Sprintf("key%03d", i), i)
+ }
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ m.Sort(stdSorter.SortByKey)
+ }
+}
+
+// BenchmarkSortByValueList measures Sort(SortAsc) on a LIST model with 100 numeric elements.
+func BenchmarkSortByValueList(b *testing.B) {
+ m, _ := model.New(model.LIST, nil)
+ for i := 99; i >= 0; i-- {
+ m.Push(i)
+ }
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ m.Sort(stdSorter.SortAsc)
+ }
+}
+
+// BenchmarkMergeHash measures merging two 50-key HASH models.
+func BenchmarkMergeHash(b *testing.B) {
+ base, _ := model.New(model.HASH, nil)
+ for i := 0; i < 50; i++ {
+ base.Set(fmt.Sprintf("base%03d", i), i)
+ }
+ inc, _ := model.New(model.HASH, nil)
+ for i := 0; i < 50; i++ {
+ inc.Set(fmt.Sprintf("inc%03d", i), i)
+ }
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ clone, _ := model.New(model.HASH, nil)
+ var k, v any
+ base.Reset()
+ for base.Next(&k, &v) {
+ clone.Set(k.(string), v)
+ }
+ clone.Merge(inc)
+ }
+}
+
+// BenchmarkMergeDeepHash measures merging nested HASH models two levels deep.
+func BenchmarkMergeDeepHash(b *testing.B) {
+ makeNested := func(prefix string, depth, width int) *model.Model {
+ root, _ := model.New(model.HASH, nil)
+ for i := 0; i < width; i++ {
+ child, _ := model.New(model.HASH, nil)
+ for j := 0; j < width; j++ {
+ child.Set(fmt.Sprintf("%s_c%d_k%d", prefix, i, j), j)
+ }
+ root.Set(fmt.Sprintf("%s_k%d", prefix, i), child)
+ }
+ _ = depth
+ return root
+ }
+ base := makeNested("base", 2, 5)
+ inc := makeNested("inc", 2, 5)
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ clone, _ := model.New(model.HASH, nil)
+ var k, v any
+ base.Reset()
+ for base.Next(&k, &v) {
+ clone.Set(k.(string), v)
+ }
+ clone.Merge(inc)
+ }
+}
+
// waitTimeout returns a channel that fires after d, used to detect deadlocks in tests.
func waitTimeout(d time.Duration) <-chan struct{} {
ch := make(chan struct{})