Repository navigation
Releases: dwoolworth/goodm
Release list
v0.8.0
goodm 0.8.0 adds the three features deferred from 0.7: change streams, collection validators derived from the schema, and population of refs inside subdocuments. It also fixes SaveMany ignoring UnsetFields. Four behavior changes are called out below. Full detail is in the CHANGELOG.
Tested against
| MongoDB | Go |
|---|---|
| 6.0.28, 7.0.45, 8.0.34 as single-node replica sets | 1.21, 1.22, 1.23 in CI; 1.26 locally |
The full suite runs with the race detector on every combination. Each pull request went through two or three adversarial review rounds until clean, and the merged release had a final cross-cutting review before this tag.
Added
- Change streams.
Watch(ctx, &Model{})opens a change stream on the model's collection and returns a*ChangeStreamwhoseNext,TryNext, andEachdeliverChangeEventvalues: operation type, resume token, cluster and wall time, namespace, document key, full document, pre-image, and update description.ChangeEvent.Decodeunmarshals the full document into a model and records it as the merge base, so a model decoded from an event can be saved withWithRetry.WatchOptionscovers the pipeline, full-document and pre-image modes (FullDocumentdefaults tooptions.UpdateLookup),ResumeAfter/StartAfter/StartAtOperationTime, batch size, max await time, collation, and expanded events.ChangeStream.Decodedecodes the current event through the collection, so client codecs andBSONOptionsapply as inFindOne. Middleware runs underOpWatchand may replaceOpInfo.Pipeline. See docs/watch.md. - Collection validators from the schema.
Schema.JSONSchema()derives a$jsonSchemadocument from the struct's Go types and goodm tags.EnforceOptions.ValidatorwithValidatorApplyinstalls it as each collection's validator (creating the collection if needed, leaving a matching validator untouched) andValidatorVerifyreports a mismatch without changing anything;ValidationLevelandValidationActionset the server's level and action. A document goodm writes always passes the validator generated from its own schema; self-marshaling types are left unconstrained. Writes the server rejects return*DocumentValidationError(matched byErrDocumentValidation) with the server'serrInfoinDetails.goodm inspect --json-schemaprints each model's validator;CollectionOptions.SkipValidatorexempts a model whose stored form the schema cannot describe. - Population of refs inside subdocuments.
PopulateandBatchPopulateaccept dotted paths ("ship.carrier","items.product") through struct, pointer, and slice fields, including self-recursive subdocument types. A path through a slice collects every element's refs into a slice target; a numeric segment ("items.0.product") selects one element.*bson.ObjectIDref fields are supported.
Changed
min/maxon a string field count characters, not bytes, matching the server'sminLength/maxLength.requiredtreats a value whose ownIsZeromethod reports zero as missing (a non-nil*time.Timepointing at the zero time), as the driver'somitemptydoes.UpdateFieldsgivennilfor a field that cannot hold one (a string, number, struct, or date) now stores the zero value the model holds instead of null.Populatechecks its arguments before looking anything up: the model must be a non-nil pointer to a struct (a slice used to be silently ignored), every target must be a non-nil pointer, and a path that can yield several ids must have a slice target, even when the field is currently empty.BatchPopulatereports a ref field of an unsupported type instead of skipping it.
Fixed
SaveManysilently ignoredUnsetFieldsandWithRetry. It now removes the unset fields from every document and zeroes them on the saved models, and returns an error forWithRetry, which it cannot honor.UnsetFieldscould remove animmutablefield; unsetting one is now rejected byUpdateandSaveMany.goodm migratedid not list planned index rebuilds; they are shown with~and counted.
Known limitations
Writes with UnsetFields (in Update and SaveMany) and the WithRetry merge use the default BSON registry, so a model whose stored form depends on codecs registered on the client cannot be unset-saved or merged with them; ChangeEvent.Decode on a detached event has the same limit, while ChangeStream.Decode does not. The driver exposes no way to read a collection's registry.
goodm 0.7.1
goodm 0.7.1 is a patch release. It fixes a lost-update bug in Update's WithRetry merge that dates from 0.5.0, the items the adversarial reviews of 0.7.0 flagged as pre-existing, and the housekeeping left after 0.7.0. One behavior change is called out below. Full detail is in the CHANGELOG.
Tested against
| MongoDB | Go |
|---|---|
| 6.0.28, 7.0.45, 8.0.34 as single-node replica sets | 1.21, 1.22, 1.23 in CI; 1.26 locally |
The full suite runs with the race detector on every combination. The change went through seven adversarial review rounds plus a confirmation pass; the last full round also ran a 160-case WithRetry matrix and a six-writer stress run on all three servers with no lost updates.
Fixed
UpdatewithWithRetrylost the caller's changes. The 3-way merge base was the model at the momentUpdatewas called, which already held the caller's edits, so the diff found nothing to apply: on a version conflict the retry wrote the database copy back and returned nil, and a same-field conflict never produced aMergeConflictError.goodm.Modelnow remembers the document as last loaded or saved through goodm (FindOne,FindByID,Find,FindOneAndUpdate,FindOneAndReplace,Create,Update,UpdateFields,SaveMany) and the merge diffs against that. The base is normalized through the model type, advances after each merge round, is used only when it describes the same document at the model's current version, and is not recorded for writes inside a transaction. The merged document is applied without resurrecting map keys oromitemptyvalues another writer removed, whilebson:"-"and unexported fields keep their values.UnsetFieldscounts as the caller's removals. A model without a base (built by hand, loaded throughFindCursor, or without an embeddedgoodm.Model) getsErrVersionConflictinstead of a merge.enumon a slice or array field rejected every non-empty value. Each element is now checked and reported asfield[i], includingUpdateFieldselement paths; nil pointer elements are skipped.UpdatedAtnot restored on failedUpdateandSaveManyitems.goodm discovertyped date fields asinterface{}; they aretime.Time.GenerateModelwithEmbedModel: falseomitted the goodm import itsinit()needs.- Several
UpdateOptionsvalues passed to one call are combined, soUnsetFields(...)andWithRetry(n)work together. After an Unset save the fields are zeroed on the model.
Changed
ErrVersionConflictno longer refreshes the model's version. The refresh added in 0.5.0 let the next plainUpdatematch the version filter and overwrite the other writer's changes. A conflicting model now keeps its stale version, inUpdateandSaveMany, until it is reloaded or saved withWithRetry. Models loaded or saved inside a transaction that later aborted must be reloaded before they are saved again.- The 0.7.0 changelog entry names the tested servers and Go versions.
Docs
Snapshot sessions in WithTransaction; enum semantics on slices; the WithRetry base and conflict rules; models after an aborted transaction.
Pull request: #11.
goodm 0.7.0
goodm 0.7.0 closes the MongoDB feature gaps found in a review of 0.6.1 and fixes the correctness bugs that review and the follow-up adversarial reviews surfaced. It contains breaking changes; see the end of these notes. Full detail is in the CHANGELOG.
Tested against
| MongoDB | Go |
|---|---|
| 6.0.28, 7.0.45, 8.0.34 as single-node replica sets | 1.21, 1.22, 1.23 in CI; 1.26 locally |
The full suite runs with the race detector on every combination. CI now starts each server as a replica set, so transactions and write concern behavior are exercised on every run (#10).
Indexes
- Field tags
index=-1,index=hashed,index=text,index=2dsphere,sparse, andttl=<duration|seconds>. CompoundIndexgainsSparse,Name,ExpireAfterSeconds(withTTL(time.Duration)),PartialFilter, andCollation;Fieldsentries accept-fieldandfield:text/hashed/2dspheremarkers.- Tags inside subdocuments create dotted-path indexes.
- One resolved
IndexDefinitionlist per schema drivesEnforce,Migrate, andgoodm inspect;Enforcedetects mismatches in keys, uniqueness, sparseness, TTL, partial filter, and collation, andMigrateplans rebuilds instead of parsing index names. Partial filters are normalized and compared canonically.
Query layer
FindByID,Count,Exists,Distinct,FindOneAndUpdate,FindOneAndReplace,FindOneAndDelete.- Projection, hint, collation, and batch size on finds; upsert (
Upsert()), array filters, hint, and collation onUpdateOne/UpdateMany; hint and collation on deletes. - Aggregation options (
AllowDiskUse, collation, hint, batch size) and new stages;Pipelinenow runs middleware.
Bulk
NewBulk: a builder overBulkWritewith per-operation failure reporting.SaveMany: theUpdatelifecycle for a slice of models, with per-document version filters and exact per-item outcomes (conflict, not found, duplicate key, write concern, transport), concurrent outside transactions and transaction-aware inside them.CreateManygainsUnordered;*BulkErrorreports failed items witherrors.Is/errors.Assupport and aWriteConcernError.
Errors, middleware, registry
ErrDuplicateKey/*DuplicateKeyErrorwith collection, index, and field names.- Middleware can rewrite
OpInfo.FilterandOpInfo.Pipeline;Update,UpdateFields,Delete, andSaveManyAND the rewritten filter with their own. - The registry is keyed by type (
SchemaOf,Unregister); custom_idtypes are supported;Registerrejects models the driver would store differently (no_idtag,goodm.Modelembedded withoutinline, duplicate bson names, invalid inline tags). UpdateFieldsvalidates its values, converts them to the field's Go type with an explicit whitelist of safe conversions, rejects immutable fields and unreachable paths, accepts dotted paths, and leaves the caller's map alone.
Fixed
min/maxskipped zero values and truncated floats.- Middleware could not change the filter.
- Schemas keyed by bare struct name collided across packages.
Populateignored the referenced model's collection options.- Nested
WithTransactionstarted a second session; a session without a transaction now gets one. - Compound text indexes always mismatched;
discoverlost index kinds, options, custom names, and second indexes on a field.
Breaking
GetAll()returns a sorted[]*Schema;PlanMigrationtakes[]*Schema.Get(name)returns false for an ambiguous name; useSchemaOf.FieldSchema.Min/Maxare*float64.min/maxapply to zero values and to slice and map lengths.unique/index/sparse/ttltags inside subdocuments now create real indexes.- Only exported fields are schema fields; inline flattening follows the driver's rules; managed fields are found by bson name.
CreateManyreturns*BulkError; unique violations everywhere are*DuplicateKeyError;UpdateFieldsreturnsValidationErrorsfor bad values and drops undeclared subdocument keys.Pipeline.Execute/Cursorrun middleware.- Minimum Go version is 1.21.
v0.6.1
Fixed
IndexMismatchRebuildno longer drops a non-unique index before recreating it as unique. On MongoDB 6.0+ (binary and featureCompatibilityVersion) the index is converted in place withcollMod(prepareUnique: true, thenunique: true), so there is no window where the collection is unindexed and a concurrent writer can insert a duplicate that would break the recreate and every subsequentEnforce. The read-only duplicate pre-check still runs first, so known duplicates never trigger acollMod. If a duplicate slips in between the pre-check and the conversion,prepareUniqueis reverted and the existing index is left untouched. On any other failure of the final step the index is left in the prepared state (present, new duplicates rejected) and the nextEnforcecompletes the conversion. Drop + recreate remains the fallback for servers that don't supportprepareUniqueand for mismatchescollModcannot fix (keys, sparse, partial, unique -> non-unique) (MSA-78).
Added
IndexSpec.PrepareUniquereports an index left in thecollMod prepareUniquestate;Enforcemismatch errors name that state explicitly, andIndexMismatchRebuildclears a stale one on a non-unique field in place.IndexSpec.HasCollation,IndexSpec.HasTTL,IndexSpec.Hidden. Indexes with these options are never converted in place, so a rebuild still normalizes them to a plain unique index.- The duplicate pre-check now mirrors unique-index semantics for array (multikey) fields and no longer conflates dotted keys such as
a.bwitha_b.
goodm v0.6.0
Fixed
Enforcenow verifies the options of existing indexes instead of accepting any index by name alone. Agoodm:"unique"field whose existing index is non-unique, sparse, or partial (and the reverse: a plainindexfield with a unique index) is reported as anEnforcementErrorby default. The same check applies to compound indexes. Previously such mismatches were silently skipped, souniquetags guaranteed nothing once any index of that name existed (MSA-76 / BUG-3136).
Added
EnforceOptions.IndexMismatchPolicy:IndexMismatchError(default, fail loudly),IndexMismatchRebuild(drop and recreate mismatched indexes; refuses to drop when rebuilding a unique index over duplicate data), andIndexMismatchIgnore(legacy name-only behavior).IndexSpectype describing an existing index (keys, unique, sparse, partial).
Changed
- Breaking:
ListExistingIndexesnow returnsmap[string]IndexSpecinstead ofmap[string]bool.
Upgrade note
The default policy is now IndexMismatchError, so services whose databases already have drifted indexes will fail at boot with a descriptive EnforcementError until the index is rebuilt (or IndexMismatchRebuild/IndexMismatchIgnore is set). This loud failure is the point of the fix — the previous behavior silently left unique constraints unenforced.
goodm v0.3.0
What's New
Subdocument Support
goodm now recursively parses goodm tags on nested struct fields. Validation, defaults, and schema introspection work at any nesting depth — no separate registration needed for subdocument types.
type Address struct {
Street string `bson:"street" goodm:"required"`
City string `bson:"city" goodm:"required"`
Zip string `bson:"zip" goodm:"default=00000"`
}
type Order struct {
goodm.Model `bson:",inline"`
Name string `bson:"name" goodm:"required"`
Address Address `bson:"address" goodm:"required"`
Items []OrderItem `bson:"items"`
}New Features
- Recursive validation — all
goodmtags (required,enum,min,max) are enforced on nested struct fields and slice elements - Dotted error paths — validation errors use MongoDB-style paths:
"address.street","items[0].name","shipping.address.city" - Subdocument defaults —
default=Xtags inside nested structs and slice elements are applied duringCreateandCreateMany - Schema introspection —
FieldSchema.SubFieldscontains parsed inner fields;FieldSchema.IsSliceis true for[]structfields
Per-Schema Read/Write Concern
- Configurable interface — implement
CollectionOptions()on your model to set read preference, read concern, and write concern per collection - All CRUD, bulk, and pipeline operations automatically use the configured options
Schema Defaults & Optimistic Concurrency
- default=X tag — zero-valued fields are set to their schema default during
CreateandCreateMany - __v version key — documents get a version field that increments on each
Update, enabling optimistic concurrency control withErrVersionConflict
Design Details
- Leaf types (
time.Time,bson.ObjectID,bson.Decimal128) are never recursed into - Circular references handled via seen-type tracking during parsing
- Nil pointer subdocuments are skipped (no auto-materialization)
- Marking a subdocument field as
immutablemakes the entire subdocument immutable
goodm v0.2.0
What's New
New Features
- BatchPopulate() -- resolve ref fields across a slice of models in a single $in query, eliminating N+1 overhead
- String length validation -- min/max tags now enforce string length in addition to numeric bounds
- Configurable drift sample size -- EnforceOptions.DriftSampleSize and DetectDrift() accept a sample size parameter (default 100)
- Makefile -- make test (with -race), make build (with ldflags version injection), make lint
Improvements
- Update optimization -- Update() skips the extra fetch when the schema has no immutable fields (1 query instead of 2)
- Duplicate registration guard -- Register() returns an error if a model is already registered
- Version injection -- CLI version is now set at build time via -ldflags, defaults to dev
- Generated code -- init() uses log.Fatal instead of panic
Quality
- golangci-lint clean -- fixed all errcheck and staticcheck issues across the codebase
- Race condition tests -- concurrent registry, middleware, and validation tests pass with -race
- Validation test suite -- comprehensive unit tests for string length, int bounds, enum, and required fields