Code is fairly settled and is in use in production systems. Backwards-compatibility will be maintained 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.
go get github.com/bdlm/modelA 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.
// 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).
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):
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 appends to a list model. It returns InvalidMethodContext on a hash model.
lst, _ := model.New(model.LIST, nil)
lst.Push(1)
lst.Push(2)
lst.Push("three")
// list is now [1, 2, "three"]// 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.
if mdl.Has("name") {
// key exists
}Every value retrieved from a model is a Value with typed conversion methods. All conversions use bdlm/cast and return an error if the conversion is not possible.
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.
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.ValueModel implements a bidirectional cursor iterator. For hash models, iteration order depends on how the model was built:
- Built via
New(HASH, map[string]any{...})orUnmarshalJSON: keys are sorted alphabetically on import. - Built via sequential
Setcalls: keys are returned in insertion order. CallSort(SortByKey)to normalize.
// 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| 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.
// 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())
}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:
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| 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 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 |
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.
Sorts the list elements by their string representations:
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]mdl.Reverse() // reverses in place; maintains hash index consistencyMerge merges all values from an incoming model into the receiver. Self-merge returns an error.
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}| 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 |
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.
Returns a new model of the same type containing only elements for which the callback returns true. The original model is not modified.
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]Returns a new model of the same type with each element replaced by the callback's return value. The original model is not modified.
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]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.
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() // 9Model 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.
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)The package also defines Marshaler and Unmarshaler interfaces for custom serialization:
bytes, err := mdl.MarshalModel() // delegates to MarshalJSON
err = mdl.UnmarshalModel(bytes) // delegates to UnmarshalJSON; no-op for "null"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.
mdl.Lock()
err := mdl.Set("key", "value") // returns ReadOnlyModel errorSetData replaces the entire data store:
// 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:
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)Every model can carry an arbitrary identifier:
mdl.SetID("user-42")
id := mdl.GetID() // anyIDs are used as the primary sort key when sorting a list of models by value, with element count as a tiebreaker.
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 |
val, err := mdl.Get("missing")
if errors.Is(err, model.InvalidIndex) {
// key not found
}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.
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))
}| 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 |
