feat(storage)!: typed options, and let Update write only the named fields - #262
Draft
Lutherwaves wants to merge 3 commits into
Draft
Lutherwaves wants to merge 3 commits into
Lutherwaves wants to merge 3 commits into
Conversation
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: trueThanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
1 task
Lutherwaves
force-pushed
the
fix/sql-update-filter-scope
branch
from
September 18, 2026 07:48
2e8d72c to
7960896
Compare
Lutherwaves
force-pushed
the
feat/storage-typed-options
branch
from
September 18, 2026 07:49
0a2bb79 to
41d5819
Compare
Lutherwaves
force-pushed
the
fix/sql-update-filter-scope
branch
from
September 20, 2026 21:07
7960896 to
ba26bcd
Compare
Lutherwaves
force-pushed
the
feat/storage-typed-options
branch
from
September 20, 2026 21:09
41d5819 to
62429f5
Compare
Contributor
Author
|
@deanefrati feedback would be welcome here |
Lutherwaves
force-pushed
the
fix/sql-update-filter-scope
branch
from
September 27, 2026 20:06
ba26bcd to
a62a543
Compare
…elds Update wrote every field of the item it was given. Two callers that each read a record and then change different fields therefore both write back the values they read for the fields they never touched, and the second write silently reverts the first. Neither caller sees an error. Add WithFields so a caller that knows which fields changed can say so, and only those are written. Select is also what makes clearing a field work: Updates on a struct skips zero-valued fields unless they are selected, so a named field is written whether or not it holds a zero value. An unknown field name, or no field at all, is an error rather than a silently wider write. The option itself could have gone in the existing params map, but that map was the wrong place to put anything else. It is stringly typed, so the compiler checks neither key nor value; it is invisible in the godoc of the operations that read it; and the CosmosDB adapter was reaching into it with bare string literals. Replace it with a typed option set across the whole interface rather than leave two extension conventions side by side: WithSortDirection replaces the sort_direction key, WithPartitionKey and WithPartitionKeyField replace pk_field and pk_value, and validation moves into one place that runs before any adapter sees a value. Three tests went away because the compiler now rejects what they asserted: two covered a non-string pk_field and one a non-[]string field list. Options validation is tested directly rather than through an adapter. Going through the SQL adapter would not pin it -- that adapter also looks every field up in the item's schema, so a rejected name there proves only that the lookup ran. An adapter with no schema to consult has nothing but this validation between a caller and an injected identifier. Refs: #261 BREAKING CHANGE: every storage operation takes `...storage.Option` in place of `params ...map[string]any`. storage.SortDirectionKey is removed; use WithSortDirection, which takes a SortingDirection rather than a case-insensitive string. The CosmosDB pk_field and pk_value keys are replaced by WithPartitionKey and WithPartitionKeyField, and an empty partition key field name now reads as unspecified instead of returning an error -- a value given without a field is still rejected. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ithout one Nothing exercised the DynamoDB or CosmosDB adapters against anything that speaks their API, so the only coverage they had was of their pure helpers. Writing that coverage found three defects. DynamoDB List appended ORDER BY whenever sortKey was non-empty, with no regard for whether a WHERE had been emitted. PartiQL refuses ORDER BY without a WHERE, so an unfiltered List failed with an opaque ValidationException from inside ExecuteStatement. It is also worse than it looks: validateSortKey rejects an empty sort key, so the `if sortKey != ""` guard is dead code and a caller cannot ask for an unordered scan. An unfiltered List is therefore not expressible at all, rather than merely unordered. Say so in an error that names the missing filter. DynamoDB List and Search emitted a bare ORDER BY with no ASC or DESC, and never read their options, so the sort direction was accepted and silently dropped on this adapter. Resolve options and emit the direction. Update forwards to a PutItem and discards its filter. That replaces the whole item -- an attribute absent from the struct is deleted, not left alone -- and a filter matching nothing still writes. Both are pinned by tests that assert the current behaviour, alongside a skipped test spelling out what honouring WithFields would look like. Fixing it means an UpdateExpression and belongs in its own change. Add conformance assertions for all four adapters. Only MemoryAdapter had one, so a drifting signature on the other three was caught only by whichever caller happened to stop compiling. The DynamoDB tests reach a real endpoint through DYNAMODB_TEST_ENDPOINT and skip without it; CI sets it against DynamoDB Local so the skip cannot become a silent pass. Checked against both DynamoDB Local and LocalStack. CosmosDB needs no emulator for what changed here: every operation resolves its options before touching the database client, so a zero-value adapter is enough to prove an invalid option is rejected rather than carried into a request. Each case fails the test if it panics, which is what it would do had validation moved after the first client use. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Update forwarded to Create, which is a PutItem, and dropped its filter:
func (s *DynamoDBAdapter) UpdateContext(ctx context.Context, item any, filter map[string]any, opts ...Option) error {
return s.CreateContext(ctx, item)
}
A PutItem replaces the whole entry, so an attribute absent from the struct was
deleted rather than left alone -- one written by another caller, one added to
the table but not the struct, one the caller deliberately left zero. And with
the filter discarded there was no way to scope a write to the entry's current
state: a filter matching nothing still wrote, and nothing ever returned
ErrNotFound. Both returned nil.
Use UpdateItem with an UpdateExpression instead, which is how DynamoDB
expresses a partial write. The filter becomes a ConditionExpression, with
ConditionalCheckFailedException mapped to ErrNotFound -- the same contract the
SQL adapter has. UpdateItem creates the entry when the key is absent, so an
attribute_exists condition on the hash key preserves "Update never creates".
Key attributes are skipped rather than rejected when building the SET clause:
without WithFields the write set is every attribute of the item, and the key is
always among them. Key attributes may still appear in the condition, so the
filter needs no special casing.
Addressing the entry needs the table's key attribute names, which is one
DescribeTable per table, cached. A table's key schema is fixed once it exists.
WithFields now means something on this adapter. Previously only the SQL and
Memory adapters honoured it.
The write set is sorted when it comes from the item rather than from
WithFields. Ranging a map would vary the generated expression between
otherwise identical calls.
Refs: #263
BREAKING CHANGE: Update on the DynamoDB adapter no longer creates a missing
entry and no longer ignores its filter. A call that relied on either -- an
upsert through Update, or a filter that does not match the entry -- now returns
ErrNotFound and writes nothing. Update also no longer removes attributes absent
from the item; pass the full item to overwrite them, or WithFields to choose.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Lutherwaves
force-pushed
the
feat/storage-typed-options
branch
from
September 27, 2026 20:25
62429f5 to
a170577
Compare
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #261 and #263. Stacked on #258 — review that first; this branch is based on it, so the diff here is only the second commit.
Problem
Updatewrites every field of the item it is given. Two callers that each read a record and then change different fields both write back the values they read for the fields they never touched, so the second write silently reverts the first. Neither caller gets an error.WithFieldslets a caller that knows which fields changed say so.Why this also replaces the params map
The option could have been another key in
params ...map[string]any. That map was the wrong place to put anything else:Updateignored it entirely on SQL while CosmosDB read"pk_field"and"pk_value"out of it as bare string literals.Adding a second typed mechanism beside it would leave two extension conventions in an interface whose whole purpose is being uniform across backends. So the map is replaced everywhere instead:
map[string]any{storage.SortDirectionKey: "DESC"}storage.WithSortDirection(storage.Descending)map[string]any{"pk_field": f, "pk_value": v}storage.WithPartitionKey(f, v)map[string]any{"pk_field": f}storage.WithPartitionKeyField(f)storage.WithFields("name", "modified_at")Validation now happens in one place,
Options.validate, before any adapter sees a value.Change
storage/options.go:Option,Options, theWith*constructors,ResolveOptions,validate.ResolveOptionsis exported because anyone implementingStorageAdapteroutside the package — a forwarding wrapper, or a test fake — receives[]Optionand would otherwise have to apply them by hand and reimplement the validation. That gap showed up on the first external consumer, not in review.*Contextvariant takes...Optionin place ofparams ...map[string]any.UpdateContextselects the named fields instead of"*", and rejects a field the item does not have.docs/observability-internals.mdinterface listing too.Selecting is also what makes clearing a field work:
Updateson a struct skips zero-valued fields unless they are selected.Breaking changes
...storage.Optioninstead ofparams ...map[string]any.storage.SortDirectionKeyis removed.WithSortDirectiontakes aSortingDirection, so the old case-insensitive"desc"string no longer works.pk_field/pk_valuekeys are replaced by the options above."pk") rather than returning an error. A value supplied without a field is still rejected, byvalidate. The old error existed because the map could hold a non-string; that case is now a compile error.allow-initial-development-versions: trueis set inrelease.yml, so this releases as v0.20.0, not v1.0.0.What a caller must change
The compiler finds every one of these, and a call that passed no params compiles
unchanged. Full guide with copy-paste agent prompts:
docs/migration.md.A wrapper, decorator or test fake that satisfies
StorageAdapterchanges itssignatures, and applies the options the way an adapter does:
One-liner for a consumer's coding agent
Testing
go build ./...,go vet ./...,go test ./... -count=1— all nine packages pass. Everything touched isgofmtclean (four files were already unformatted onmainand are left alone).Each new guard was checked by reverting the implementation and watching it fail:
TestSQLAdapterUpdateWritesOnlyNamedFieldsSelectbranchname="original"TestSQLAdapterUpdateNamedFieldWritesZeroValueSelectbranchcolorwipedTestSQLAdapterUpdateRejectsUnknownFieldSelectbranchTestSQLAdapterUpdateRejectsEmptyFieldListvalidatecheckTestResolveOptionsRejectsUnsafeFieldNameTestSQLAdapterUpdateRejectsUnsafeFieldNameis deliberately not in that table: it passes with the regex removed, becauserequireKnownFieldsrejects"name, color"as an unknown field first. It guards the schema lookup, not the regex. That is whyoptions_internal_test.goexercisesResolveOptionsdirectly — an adapter with no schema to consult has nothing but that validation between a caller and an injected identifier.Two tests pass with or without the change, by design:
TestSQLAdapterUpdateWithoutFieldsWritesEveryField(the default is unchanged) andTestSQLAdapterUpdateNamedFieldsRespectFilter(WithFieldsnarrows what is written, never what is matched).Three existing tests were deleted because the compiler now rejects what they asserted — two covered a non-string
pk_field, one a non-[]stringfield list.Adapter coverage (second commit)
Nothing exercised the DynamoDB or CosmosDB adapters against anything speaking their API. Writing that coverage found three pre-existing defects, all fixed or pinned here:
Listcould not do an unfiltered scan at all.ORDER BYwas appended wheneversortKey != "", regardless of whether aWHEREhad been emitted, and PartiQL refusesORDER BYwithout one — so an unfilteredListcame back as an opaqueValidationExceptionfrom insideExecuteStatement. Worse than it first appears:validateSortKeyrejects an empty sort key, so theif sortKey != ""guard is dead code and a caller cannot opt out of ordering. Now fails with an error that names the missing filter.List/Searchemitted a bareORDER BYwith noASC/DESCand never read their options. Fixed — and the guard uses a composite-key table, because with only a hash key a partition-scoped filter returns one item and the ordering is unobservable, so the test would have passed either way.Updatewas a whole-itemPutItemthat discarded its filter — an attribute absent from the struct was deleted, not left alone, and a filter matching nothing still wrote, always returningnil. Filed as issue: storage/dynamodb: Update replaces the whole item and ignores its filter #263 and fixed in the third commit:UpdateItemwith anUpdateExpression, the filter as aConditionExpression,ConditionalCheckFailedException→ErrNotFound, andattribute_existson the hash key soUpdatestill never creates.WithFieldsnow means something on this adapter too.Conformance assertions added for all four adapters; only
MemoryAdapterhad one, so a drifting signature on the other three was caught only by whichever caller stopped compiling.How it runs. The DynamoDB tests reach a real endpoint via
DYNAMODB_TEST_ENDPOINTand skip without it. CI now startsamazon/dynamodb-localand sets it, so the skip cannot become a silent pass. Verified locally against both DynamoDB Local and LocalStack.CosmosDB needs no emulator for what changed here: every operation resolves its options before touching the database client, so a zero-value adapter proves an invalid option is rejected rather than carried into a request. Each case fails if it panics — which is what it would do had validation moved after the first client use.
Not covered
CosmosDB's read/write paths still have no coverage against the Azure emulator.
CountContextremains the// TODO Implementstub returning(0, nil)on both adapters (#200), untouched here.WithFieldsis accepted and ignored by the CosmosDB adapter: only SQL, Memory and DynamoDB honour it. A CosmosDB caller that follows the new docs therefore gets no error and still loses concurrent partial updates. Either honour it in the read/merge/replace path or reject it on that adapter — accepting it silently is the one shape a consumer cannot debug.Third commit: the DynamoDB Update fix (#263)
Addressing the entry needs the table's key attribute names, so there is now one
DescribeTableper table, cached — a key schema is fixed once the table exists. Key attributes are skipped rather than rejected when building theSETclause, because withoutWithFieldsthe write set is every attribute of the item and the key is always among them; key attributes may still appear in the condition, so the filter needs no special casing. The write set is sorted when it comes from the item rather thanWithFields, since ranging a map would vary the generated expression between otherwise identical calls.Its guards were written first and watched fail:
UpdateRespectsItsFilter(= <nil>; want ErrNotFound),UpdateDoesNotCreateMissingItem(same), andUpdateHonoursWithFields(got {Id:d1 Name:renamed Color:}; want color=red).UpdateWithoutFieldsWritesEveryAttributepasses either way by design — it pins the default, which is unchanged.Verified against both DynamoDB Local and LocalStack, which differ on conditional expressions.
This is a third breaking change:
Updateno longer creates a missing entry, no longer ignores its filter, and no longer removes attributes absent from the item.