Repository navigation
Support child slice and slice pointer fields in Struct mappings - #248
AdamMagued wants to merge 1 commit into
Conversation
Add support for expanding slice fields in Struct mappings to model one-to-many joined relations. Update Struct.Addr and Struct.Values to handle slice and slice pointer elements dynamically during row scanning and writing. Introduce SliceFields and SliceColumns for inspecting projected slice fields, PruneEmptySliceElements for removing empty rows produced by LEFT JOIN queries, and ScanJoined / ScanJoinedWithCols for aggregating child records across multi-row result sets.
|
Thanks @AdamMagued for the contribution. Mapping joined rows into nested slices is non-trivial reflection work, and the API surface you added ( I want to be candid about why #187 has stayed open for so long. It is not that the issue was forgotten. It is that I have never found a way to handle JOIN deserialization that is both simple enough for this library — the ORM side here is deliberately thin and only builds on One more principle matters here. This PR lands right in that gap. It solves a narrow part of the problem — one parent and one child slice — while introducing new problems and changing existing behavior:
Because this changes the behavior of existing public API while still not being correct for the general one-to-many case, I do not think we can merge it as it is. I would like to ask you to reconsider the approach: keep slice expansion explicitly opt-in so existing structs are unaffected, and move the aggregation into a separate, explicit API that owns the grouping key instead of extending I have left inline comments on the specific spots. Thanks again for taking the time to work on this. |
huandu
left a comment
There was a problem hiding this comment.
Thanks for the work here. I left inline notes on the individual spots; my overall reasoning is in my summary comment on this PR.
| nextTags = removeDuplicatedTags(nextTags) | ||
| } | ||
|
|
||
| sfs.parse(targetType, mapper, dbtag+".", appendFieldIndex(index, i), false, newSlicePath, newSliceDepth, nextTags) |
There was a problem hiding this comment.
Nested slices (a slice of structs that itself contains a slice of structs) are accepted by the parser — Columns() returns [id orders.id items.id] and SliceFields() lists both — but Addr returns nil at scan time because the intermediate slice has no element yet, so ScanJoined fails with the generic cannot get scan addresses for .... Either support this shape or reject it in NewStruct with a clear error; silently accepting it and failing later is the worst combination.
| fieldtag := field.Tag.Get(FieldTag) | ||
| tags := splitTags(fieldtag) | ||
| if len(inheritedTags) > 0 { | ||
| tags = append(tags, inheritedTags...) |
There was a problem hiding this comment.
Propagating the parent's fieldtag to child fields changes the column set of existing structs, and not only for slice expansions. On master, for a nested struct field tagged g1, WithoutTag("g1").Columns() returns [id in.a b]; with this change it returns [id b], i.e. columns are silently dropped from the generated SELECT/UPDATE. WithTag results change as well. If the inheritance is needed for the slice case, please restrict it to slice children and add tests for both WithTag and WithoutTag (plus a README note).
| } | ||
|
|
||
| dt := dereferencedType(t) | ||
| if dt.Kind() == reflect.Slice { |
There was a problem hiding this comment.
A slice field whose type itself implements sql.Scanner/driver.Valuer is now expanded, because this only inspects the element type and never the field type:
type Addresses []Address // implements Scan/Value, e.g. for a JSON/array column
type Row struct {
Addr Addresses // db:"addr"
}
For that struct, Columns() goes from [id addr] to [id addr.city addr.zip], and Addr()/Values() change with it (verified against current master). That is a silent break for existing users — they would have to add fieldopt:"noexpand" to get the old behavior back. canExpandStructType already refuses types implementing Scanner/Valuer; this path should apply the same check (including for *[]T), or slice expansion should be opt-in.
| assignments := make([]string, 0, len(tagged.ForWrite)) | ||
|
|
||
| for _, sf := range tagged.ForWrite { | ||
| if sf.SliceDepth > 0 { |
There was a problem hiding this comment.
Skipping child slice fields here is a sensible improvement, but the INSERT path is left inconsistent: buildColsAndValuesForTag still emits the slice field as an ordinary column, so InsertInto produces INSERT INTO t (id, kids) VALUES (?, ?) with the whole slice as the second argument. (This happens on master too, so it is not a regression — but the same guard should apply to both write paths.)
| if sliceElemType.Kind() == reflect.Ptr { | ||
| childStructPtr := reflect.New(sliceElemType.Elem()) | ||
| childStruct = childStructPtr.Elem() | ||
| sliceField.Set(reflect.Append(sliceField, childStructPtr)) |
There was a problem hiding this comment.
Addr is now stateful: every call appends a new child element to each slice field, so a method that used to be a pure "give me the scan destinations for this struct" conversion now depends on how many times it has been called. Calling it twice for the same row (a retry, or reusing the struct for the next parent row) silently duplicates children, and there is no way to reset. This matters beyond the individual bug: Struct is documented as stateless and thread-safe ("All methods in Struct are thread-safe. We can define a global variable to hold a Struct and use it in any goroutine."), and it holds no lock precisely because it never accumulates per-query state. Aggregation state like this needs an explicit owner — a separate value created per query — instead of living implicitly in Addr.
| var result []string | ||
| for _, sf := range tagged.ForRead { | ||
| if sf.SliceDepth > 0 && len(sf.SliceIndex) > 0 { | ||
| dotIdx := strings.IndexByte(sf.Alias, '.') |
There was a problem hiding this comment.
SliceFields/SliceColumns recover the slice field name by string-splitting sf.Alias on the first .. That is fragile: the alias is not a reliable identifier (two slice fields sharing a db tag in different nested structs produce the same prefix, so the seen dedup here collapses them into one name while SliceColumns matches both), whereas the parser already knows the slice field and its SliceIndex. Deriving this from the parser would be robust and would let SliceFields/SliceColumns share one implementation.
|
|
||
| // PruneEmptySliceElements removes any elements from child slices on st where all fields | ||
| // are zero or empty, such as from LEFT JOIN query rows where child records were NULL. | ||
| func (s *Struct) PruneEmptySliceElements(st interface{}) { |
There was a problem hiding this comment.
Using "all fields are zero" as the test for "this LEFT JOIN child row was NULL" has two problems: (1) with a real driver, scanning NULL into a non-pointer child field (say uint64) fails with converting NULL to uint64 is unsupported before any pruning can happen, so this only works when the child struct uses pointer/Null* fields — which is not documented; (2) it also removes legitimate child rows whose selected columns happen to be zero. Judging NULL from a designated join-key column (or letting the caller pass one) would be reliable. Note the new test for this path feeds zero values through a mock that skips nil, so the LEFT JOIN case is effectively untested.
| // ScanJoined scans multiple rows from a scannable row iterator into st, | ||
| // populating scalar fields and aggregating child slice records across all rows. | ||
| // Any empty child elements from LEFT JOIN NULL records are pruned upon completion. | ||
| func (s *Struct) ScanJoined(rows ScannableRows, st interface{}) error { |
There was a problem hiding this comment.
ScanJoined folds every row into a single destination struct with no grouping by a parent key. Feeding it rows for two parents (user 1: 1 row, user 2: 2 rows) yields ID=2, Name=Bob, len(Expenses)=3 — user 1's child row ends up under user 2. With two child slices the rows are paired up too (3 expenses × 2 roles => both slices end up with 6 elements), because each row appends to every slice. So the API is only incidentally correct for "one parent row set + one child slice", which the doc comment does not state. Making this correct needs a collector that groups by an explicit key and returns a slice of parents; otherwise grouping belongs to the caller.
| // Any empty child elements from LEFT JOIN NULL records are pruned upon completion. | ||
| func (s *Struct) ScanJoined(rows ScannableRows, st interface{}) error { | ||
| if rows == nil { | ||
| return fmt.Errorf("sqlbuilder: nil rows") |
There was a problem hiding this comment.
Small but visible: errors here are prefixed sqlbuilder:, while the rest of the package uses go-sqlbuilder: — e.g. ErrInterpolateNotImplemented = errors.New("go-sqlbuilder: interpolation for this flavor is not implemented") in flavor.go. Please make the prefix consistent.
|
|
||
| userStruct := NewStruct(new(UserWithExpenses)) | ||
| sb := userStruct.SelectFrom("users u"). | ||
| Join("expenses e", "u.id = e.user_id") |
There was a problem hiding this comment.
This example builds SQL no server will accept: the slice field's db tag is expenses, so the columns are emitted as expenses.id, ..., but the table is aliased to e, and expenses is then not a valid table reference (PostgreSQL: missing FROM-clause entry for table "expenses"). The convention documented in the README is that the db tag is the table alias (db:"post" with Join("posts post", ...)), so this should be db:"e" or a join without an alias. As a copy-paste template it should be fixed — and the new API needs a README section, since docs are currently untouched in this PR.
Fixes #187
Summary
Add support for expanding child slice and slice pointer fields in Struct to map one-to-many relationship rows into parent structs with nested slices.
Key Changes