Two integration-test failures uncovered after the initial commit: 1) GetTilesByRegionAsync outer ORDER BY referenced 'updated_at' but the inner DISTINCT ON subquery aliased it to 'UpdatedAt' (Postgres folds to 'updatedat'). DISTINCT ON already guarantees one row per (latitude, longitude, ...) so the third tiebreak was unreachable; removed it. 2) Dapper 2.1.35 silently bypasses SqlMapper.TypeHandler<T> for enum types during read deserialization (Dapper issue #259). The TileSourceTypeHandler worked for writes but reads fell through to Enum.TryParse, which cannot map 'google_maps' to GoogleMaps. Pivoted: TileEntity.Source is now a string (the wire value). TileSource enum stays as the public producer surface in Common.Enums; TileSourceConverter (Common.Enums) provides ToWireValue / FromWireValue / IsValidWireValue at the boundary. TileSourceTypeHandler deleted; registration removed from DapperEnumTypeHandlers.RegisterAll. tile-storage.md Inv-5 amended to document the storage choice. _docs/LESSONS.md L-001 records the Dapper bypass for future cycles. Full suite passes (213 unit + integration suite incl. AZ-484 AC-1..AC-5, security SEC-01..SEC-04, AZ-356/362/357). Co-authored-by: Cursor <cursoragent@cursor.com>
2.4 KiB
Engineering Lessons
Recurring bugs, surprising library behaviors, and process insights extracted from completed cycles. Newest at the top. Keep entries short — this is for fast scanning at the start of new cycles, not exhaustive history.
L-001 — Dapper TypeHandler<T> is bypassed for enum types during read deserialization
Cycle: 1 (AZ-484)
Discovered by: integration test failure (Error parsing column 12 (source=google_maps - String)); root-caused via web search to long-standing Dapper issue #259.
Affects: Dapper 2.1.35 (and most other versions until the proposed Settings.PreferTypeHandlersForEnums opt-in in PR #2200, not yet merged).
What happens
Registering SqlMapper.AddTypeHandler(new MyEnumHandler()) for an enum type — even via SqlMapper.TypeHandler<TEnum> — works for writes (the handler's SetValue is invoked for parameter binding) but is silently bypassed for reads. Dapper's IL-emitted deserializer checks IsEnum first and falls back to Enum.TryParse(string, ignoreCase: true).
Why this is dangerous
If the enum's wire string happens to match a member name case-insensitively (e.g., RegionStatus.Failed ↔ "failed"), the bypass goes unnoticed and round-trip works accidentally. The bug only surfaces when the wire format diverges from the C# member name (e.g., TileSource.GoogleMaps ↔ "google_maps" — Enum.TryParse("google_maps") does not match GoogleMaps because of the underscore).
Recommended approach
- Do not rely on
SqlMapper.TypeHandler<TEnum>for read-side enum mapping unless the wire values match the enum member names case-insensitively. - For enums whose wire format diverges (snake_case, kebab-case, custom IDs), store the entity field as
stringand provide an explicit converter (*Converter.ToWireValue/FromWireValue) for use at the service-layer boundary. This is what AZ-484 does forTileEntity.Source↔TileSourceConverter. - Unit-test the converter directly. Do not assume that round-tripping through Dapper proves anything for enums.
Detection
- Unit tests of the type handler in isolation will pass even when the handler is bypassed at runtime.
- Failure surfaces only at integration-test time when the actual SELECT runs.
- If you must keep an enum-typed field, write at minimum one integration test that reads the enum back through Dapper from a real database row.