Changelog¶
All notable changes to this project will be documented in this file.
0.8.0 - 2026-10-25¶
This release adds the three features deferred from 0.7: change streams, collection validators derived from the schema, and population of refs inside subdocuments. Tested against MongoDB 6.0.28, 7.0.45, and 8.0.34 running as single-node replica sets, with Go 1.21, 1.22, and 1.23 in CI and Go 1.26 locally; the suite runs with the race detector on every combination.
Added¶
- Change streams.
Watch(ctx, &Model{})opens a change stream on the model's collection and returns a*ChangeStreamwhoseNext/TryNext/EachdeliverChangeEventvalues: 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;DecodeBefore,DecodeID, andObjectIDread the pre-image and key.WatchOptionscovers the pipeline, full-document and pre-image modes (FullDocumentdefaults tooptions.UpdateLookup),ResumeAfter/StartAfter/StartAtOperationTime, batch size, max await time, collation, and expanded events. Middleware runs under the newOpWatchand may replaceOpInfo.Pipeline.ErrNoFullDocumentis returned when an event carries no document to decode.ChangeStream.DecodeandDecodeBeforedecode the current event through the collection, so client codecs andBSONOptionsapply as inFindOne;ChangeEvent.Decodeon a detached event uses the default registry. - Collection validators from the schema.
Schema.JSONSchema()derives a$jsonSchemadocument from the struct's Go types and goodm tags (bsonTypeper the driver's encoding,required,enum, andmin/maxasminimum/maximum,minLength/maxLength,minItems/maxItems, orminProperties/maxProperties; nilable fields allow null; the zero value of a non-required enum field is allowed; self-marshaling types are left unconstrained).EnforceOptions.ValidatorwithValidatorApplyinstalls it as each collection's validator (creating the collection if needed, leaving a matching validator untouched, overwriting a different one) andValidatorVerifyreports a mismatch without changing anything;ValidationLevelandValidationActionset the server's level and action. A collection registered for more than one model is refused. Writes the server rejects return*DocumentValidationError(matched byErrDocumentValidation) with the server'serrInfoinDetails, from the raw and bulk operations too.goodm inspect --json-schemaprints each model's validator.CollectionOptions.SkipValidatorexempts a model whose stored form the schema cannot describe (client-registered codecs). - 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 that crosses a slice collects the distinct refs of every element into a slice target; a numeric segment ("items.0.product","tags.1") selects one element. A*bson.ObjectIDref field is supported as well.
Changed¶
min/maxon a string field count characters, not bytes, matching the server'sminLength/maxLength.requiredtreats a value whose ownIsZeromethod reports zero (a non-nil*time.Timepointing at the zero time) as missing, 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 every path and target before fetching anything, so a bad entry leaves no ref populated: 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 (an array ref, or a path through a slice without an index) must have a slice target, even when the field is currently empty.BatchPopulatereports a ref field of an unsupported type instead of skipping it.UpdateFieldson an element of anenumslice (tags.1) checks the value even when it is zero, asValidatechecks every element; previously anilor""element was accepted and the model then failed its nextUpdate.
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, since the immutable check compares the model (which still held the value) with the stored document; unsetting an immutable field is now rejected byUpdateandSaveMany.goodm migratedid not list planned index rebuilds, so they were applied without appearing in the plan or its summary; rebuilds are now shown with~and counted.
0.7.1 - 2026-10-24¶
Fixed¶
UpdatewithWithRetrylost the caller's changes. The merge base was taken from the model at the momentUpdatewas called, which already held the caller's edits, so the 3-way diff found nothing to apply: on a version conflict the retry wrote the database copy back and returned nil, and a genuine 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. A model with no such base (built by hand, loaded throughFindCursor, or without an embeddedgoodm.Model) getsErrVersionConflicton conflict instead of a merge. The base is normalized through the model type, so stored documents that differ from the struct in missing or extra fields, integer width, or map key order do not look like changes, and it advances after each merge round so later rounds ofWithRetry(n)work. The merged document is applied over zeroed BSON fields, so map keys andomitemptyvalues another writer removed are not resurrected from the model while fields tagged-, unexported fields, and pointer identities are kept;UpdateFieldsadvances the base by the fields it wrote rather than by the whole model, so unsaved in-memory edits still count as changes; fields named inUnsetFieldscount as the caller's removals, so a concurrent change to one of them is a conflict instead of being deleted; and a NaN field no longer causes a false conflict on every merge.Finddecodes documents one at a time to record them, and each loaded model keeps a copy of its stored form.enumon a slice or array field compared the whole slice's string form against the allowed values, so every non-empty value was rejected; each element is now checked (nil pointer elements are skipped) and an offending one is reported asfield[i].UpdateFieldsappliesenumto an element path such astags.1as well.GenerateModelwithEmbedModel: falseemitted code that did not import goodm although itsinit()callsgoodm.Register.UpdateandSaveManyleft a failed model'sUpdatedAtat the attempted time while restoring its version;UpdatedAtis restored too.goodm discovertyped date fields asinterface{}because BSON dates decode asbson.DateTime; they are nowtime.Time.- A snapshot session passed to
WithTransactionis documented to return an error. - Several
UpdateOptionsvalues passed to one call are now combined (UnsetFields(...)withWithRetry(n)); previously only the first was used. After a successful save withUnsetFieldsthe unset fields are zeroed on the model; a laterUpdatewrites their zero value unless they are taggedomitempty.
Changed¶
- Behavior change:
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), so saving it again conflicts until it is reloaded or saved withWithRetry. Writes inside a transaction record no merge base, and a base is used only when it describes the same document at the model's current version, so neither a write from a transaction that later aborted nor a model refreshed by a cursor decode or raw unmarshal is merged against a stale base; such conflicts returnErrVersionConflict. The merge re-reads from the primary. - The two internal filter-scoping helpers added by #8 and #9 are merged into one.
0.7.0 - 2026-10-24¶
This release closes the main MongoDB feature gaps found in a review of 0.6.1 and fixes several correctness bugs. It contains breaking changes; see the end of this entry.
Tested against MongoDB 6.0.28, 7.0.45, and 8.0.34 running as single-node replica sets, with Go 1.21, 1.22, and 1.23 in CI and Go 1.26 locally; the suite runs with the race detector on every combination.
Added¶
- Index types and options. Field tags:
index=-1,index=hashed,index=text,index=2dsphere,sparse, andttl=<duration|seconds>.CompoundIndexgainedSparse,Name,ExpireAfterSeconds(with theTTL(time.Duration)helper),PartialFilter, andCollation, and itsFieldsentries accept-field,field:text,field:hashed, andfield:2dspheremarkers. Index tags inside subdocuments now create dotted-path indexes (see Changed).Enforcedetects mismatches in sparseness, TTL, partial filter, and collation as well as keys and uniqueness; the fields of a compound text index are compared as a set. Schema.IndexDefinitions()and theIndexDefinitiontype: the single resolved list of indexes a schema expects.Enforce,Migrate, andgoodm inspectall work from it.Migratenow plans anActionRebuildIndexfor indexes whose options differ from the schema and executes it through the same in-place unique conversion asEnforce.MigrationAction.Definitioncarries the expected index.ErrDuplicateKeyand*DuplicateKeyError(withCollection,Index, andFields) for unique index violations fromCreate,CreateMany,Update,UpdateFields,UpdateOne,UpdateMany,FindOneAndUpdate,SaveMany, andBulk.- Query layer:
FindByID(accepts hex strings for ObjectID models),Count,Exists,Distinct,FindOneAndUpdate,FindOneAndReplace, andFindOneAndDelete.FindOneAndUpdateaddsupdated_at,__v, and on upsertcreated_attobson.D/bson.Mupdates; aggregation-pipeline updates are passed through unchanged.FindOptionsgainedProjection,Hint,Collation, andBatchSize.UpdateOptionsgainedUpsert(with theUpsert()shorthand),ArrayFilters,Hint, andCollationfor the raw operations;DeleteOptionsgainedHintandCollation. NewBulk: a builder for heterogeneousBulkWritebatches (InsertOne,UpdateOne,UpdateMany,ReplaceOne,DeleteOne,DeleteMany,Unordered) with per-operation failure reporting.SaveMany: theUpdatelifecycle for a slice of models, with per-document version filters and per-index classification of conflicts (ErrVersionConflict), missing documents (ErrNotFound), duplicate keys, and write concern or transport failures. Each document is replaced with its own conditional request, issued concurrently outside transactions, because MongoDB's bulk results cannot say which replacement matched. Racing writers always see exactly one success. Transport errors are reported as "outcome unknown" (the server may have applied them) and items not sent because the context was already done asErrNotAttempted, which wraps the context error; inside a transaction a server error on any item has aborted the transaction, so every item is reported as not saved, while conflicts, missing documents, and client-side failures leave it running; anAfterSaveerror is reported for its item after the other hooks run and does not undo the save. A batch containing the same_idtwice is rejected. Middleware may scope the batch throughOpInfo.Filter, which starts as an empty document.*BulkErrorwith per-itemBulkWriteErrorentries, aResult, and aWriteConcernError; returned byCreateMany,SaveMany, andBulk.Execute.errors.Is/errors.Assee every item's error. When the write concern was not met,WriteConcernErroris set and the affected writes are treated as unknown:CreateManyandBulkrun no after-hooks and theirResultis unreliable;SaveManylists each affected item, rolls its version back, and skips itsAfterSave.CreateOptions.UnorderedforCreateMany;AfterCreatenow runs only for models that were actually inserted (inside a transaction a server rejection has aborted the transaction, so none are, andCreateManyreports every item as not inserted), and anAfterCreateerror is reported in the*BulkErrorfor its item instead of aborting the remaining hooks.BulkResult.UpsertedCountandUpsertedIDs.- Pipeline:
PipelineOptions.AllowDiskUse,Collation,Hint,BatchSize; stagesUnwindPreserve,LookupPipeline,Set,Unset,ReplaceRoot,Facet,Sample.ExecuteandCursorrun middleware under the newOpAggregate. - Middleware may now modify
OpInfo.Filterand the newOpInfo.Pipeline; the operation reads them after the chain runs. Every find,UpdateOne,UpdateMany,DeleteOne,DeleteMany, theFindOneAnd*family, and aggregation use the rewritten filter directly;Update,UpdateFields,Delete, andSaveManyAND it with their_idand version filters, reporting out-of-scope documents asErrNotFound.Bulkoperations carry their own filters andPopulateruns no middleware, so neither is scoped. New op typesOpSaveMany,OpAggregate,OpBulkWrite. - Registry:
SchemaOf(model)resolves by type,Unregister(model),Schema.Type,Schema.PkgPath,Schema.QualifiedName(). - Custom
_idtypes: the ID field is the one taggedbson:"_id", directly or through embedded structs taggedbson:",inline", and may be any type (see Changed for theRegisterchecks). ObjectIDs are still generated automatically; other types must be set beforeCreateor inBeforeCreate, and a zero value is rejected.Registerrejects a model with no_idfield. TransactionOptions.Transactionfor driver-level transaction options.goodm discoverreads index key directions and kinds (including the fields of text indexes), sparse, and TTL into field tags, emits struct-literalIndexes()entries for indexes with options or custom names and for single-field indexes on paths that are not generated fields, and leaves a TODO comment for partial filters and collations it cannot read back.FieldSchema.GoType();DiscoveredField.IndexKind,Sparse, andTTL;DiscoveredIndex.Sparse,ExpireAfterSeconds,HasPartialFilter,HasCollation,Tagged,SingleFieldTag(), andDerivedName().PlanMigrationplans the indexes of models that share a collection as one set, judges drift against the union of their fields, and fails if two models define the same index name differently (partial filters and collations are compared by meaning, not spelling).- Partial filters are compared canonically everywhere, so a nested
bson.Min aPartialFilterno longer makesEnforcereport a mismatch at random. goodm discoverexpresses at most one index per field as tags; a second index on the same field (such as a hashed index beside a unique one), and any index on a field the embeddedgoodm.Modelprovides (such as a TTL oncreated_at), becomes anIndexes()entry instead of being merged into an invalid tag or lost.- Partial filters are normalized to a canonical ordered form when the index definition is built, so a filter written with
bson.Mis created and compared deterministically; comparison keeps integers exact, treats the documents inside$and/$or/$noras filters, and keeps key order inside exact-match subdocuments. goodm discoverwith the embedded model no longer emits a__vfield that would shadowModel.Version.
Fixed¶
min/maxwere skipped for zero values, somin=1accepted0, and floats were truncated to integers before comparison, somax=10accepted10.9andmin=0accepted-0.5. Bounds now apply to every present value and compare numerically; only a nil pointer is treated as absent.- Middleware could not change the filter: operations closed over their original argument. The soft-delete and tenant-scoping patterns in the docs now work.
- Schemas were keyed by bare struct name, so a second
Usertype in another package failed to register and an unregistered type with a registered name silently used the wrong collection. The registry is keyed byreflect.Type. UpdateFieldsskipped validation of the values it set, allowed immutable fields to change, rejected dotted paths into subdocuments, and mutated the caller's map. Each value is now converted to the field's Go type before validation, allowing only conversions that keep its meaning: numbers to numbers of another width when representable, documents (bson.M,bson.D, maps, structs of another type) to structs and maps by bson name, arrays to slices and arrays element by element, hex strings to ObjectIDs, and anything into aninterface{}field; everything else (a bool for an int, a string for a date, a document for an array, an integer a float field cannot hold exactly, NaN) is aValidationError; afloat64into afloat32field is rounded.bson.DateTimeconverts totime.Time, genericbson.Binaryto[]byte, and any document to abson.Dfield; struct sources are read field by field so theirtime.Timeand[]bytevalues keep their types. The converted value is both what is stored and what is copied onto the model, so a laterUpdatecannot undo it;nilis stored as null. A path the model cannot reach, such as an index past the end of a slice, is rejected. Keys a subdocument map carries that the schema does not declare are dropped, asUpdatedrops them.Migratereconstructed index keys by parsing index names and compared by name only; it now shares index definitions and mismatch detection withEnforce.PopulateandBatchPopulateignored the referenced model's collection options (read preference and concerns).- A nested
WithTransactionstarted a second session; it now joins the transaction already running in the context's session, detected before the callback runs so an error the callback returns can never be mistaken for nesting. A context carrying a session without an active transaction gets a transaction started on that session. - The hooks documentation listed the immutable check after
BeforeSave; it runs before. - The
FindOnefamily compares driver errors witherrors.Isinstead of==.
Changed¶
- Breaking:
GetAll()returns[]*Schemasorted by collection and qualified name instead ofmap[string]*Schema;PlanMigrationtakes[]*Schema. - Breaking:
Get(name)returnsfalsewhen more than one registered type has that name. UseSchemaOf. - Breaking:
FieldSchema.MinandMaxare*float64. - Breaking:
CreateManyreturns a*BulkErrorfor write failures instead of a wrapped driver error, and unique violations everywhere are*DuplicateKeyError(the driver error remains reachable througherrors.As). - Breaking:
Pipeline.ExecuteandPipeline.Cursornow run middleware. - Breaking: validation of
min/maxno longer skips zero values (see Fixed), and the bounds now apply to the length of slices and maps as well as strings, so a nil slice taggedmin=1fails. Optional bounded fields should be pointers. - Breaking: schema parsing follows the driver's default registry: any exported struct or pointer-to-struct field tagged
bson:",inline", embedded or named, is flattened; any other exported embedded struct is a subdocument field named by its tag or lowercased type name; unexported fields and embeds are ignored; an inline map declares no fields. The managed fields are located by their bson names (created_at,updated_at,__v) rather than by Go field name, so a promoted field with the same Go name elsewhere no longer disables timestamps or versioning; aCreatedAtfield stored under another bson name is no longer managed.Registeralso rejects duplicate bson names inside subdocument types. Validation paths, index names, andUpdateFieldspaths of a non-inline embed now live under its name. A model that embedsgoodm.Modelwithout the inline tag, that has no field taggedbson:"_id"among the fields the driver stores, or that stores two fields under the same bson name is rejected byRegister. - Breaking:
UpdateFieldsdrops keys of a subdocument value that the schema does not declare, asUpdatedoes; previously they were written. - Breaking:
unique,index,sparse, andttltags on fields inside subdocuments were silently ignored before and now create real (dotted-path, multikey) indexes. Auniquetag inside an array of subdocuments indexes every element, and documents whose array is empty or missing all index asnulland collide with each other; useunique,sparseor a partial filter for such fields, and expectEnforceto fail if existing data already violates the new index. - Breaking:
UpdateFieldsnow returnsValidationErrorsfor values that violate the schema or target immutable fields. IndexSpecgainedExpireAfterSeconds,PartialFilter, andCollation; text indexes report their keys asfield: "text".- Minimum Go version is 1.21.
0.6.1 - 2026-09-25¶
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.
0.6.0 - 2026-09-24¶
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.
0.5.0 - 2026-04-21¶
Added¶
WithRetry(n)option forUpdate()with 3-way field-level merge on version conflict. On conflict, re-reads the document, diffs caller's changes against the other writer's changes, and auto-merges if the changed fields are disjoint. Returns*MergeConflictError(with field names) when both sides modified the same field. (#5)*MergeConflictErrorerror type listing the conflicting bson field names.- Auto-refresh of in-memory version on version conflict (even without retry) to prevent cascading write failures.
0.4.0 - 2026-04-20¶
Added¶
UpdateFields()for partial$setupdates without optimistic locking — ideal for concurrent writers touching disjoint fields (e.g. progress tracking, heartbeats). Runs middleware, setsupdated_at, increments version, and reflects changes back onto the struct. (#4)UnsetFields()option forUpdate()to atomically remove fields from MongoDB documents viaReplaceOne. Validates against schema to prevent unsetting required, managed, or unknown fields. (#3)
Changed¶
- Reduced cyclomatic complexity across 9 functions (all now ≤15) for Go Report Card gocyclo score improvement.
- Fixed
gofmt -sformatting ininspect.go,generate.go, andmiddleware.go.
0.3.0 - 2026-03-29¶
Added¶
- Documentation site with mkdocs-material and GitHub Pages deployment.
- CI workflow with multi-version Go and MongoDB matrix testing.
- Example tests and README improvements.
0.2.0 - 2026-03-28¶
Added¶
- Subdocument support for nested struct validation, defaults, and schema introspection.
- Schema defaults on Create and versionKey optimistic concurrency.
0.1.1 - 2026-03-27¶
Added¶
- Makefile, duplicate registration guard, ldflags version, and BatchPopulate.
- Array ref support in Populate and BatchPopulate.
0.1.0 - 2026-03-26¶
Added¶
- Initial release: schema-driven ODM for MongoDB in Go.
- CRUD operations with lifecycle hooks, validation, middleware, and population.
- CLI with discover, migrate, and inspect commands.