Skip to content

feat(rain): expand model scanning and custom type mapping#19

Merged
cungminh2710 merged 2 commits into
mainfrom
codex/linear-mention-qc-230-expand-model-scanning-and-custom-typ
Mar 29, 2026
Merged

feat(rain): expand model scanning and custom type mapping#19
cungminh2710 merged 2 commits into
mainfrom
codex/linear-mention-qc-230-expand-model-scanning-and-custom-typ

Conversation

@cungminh2710
Copy link
Copy Markdown
Contributor

Motivation

  • Make row scanning more flexible so real-world structs with embedded pointers, custom scanner types, and a broader set of nullable primitive pointers can be scanned without manual plumbing.
  • Prefer predictable, explicit handling and easier extensibility instead of ad-hoc reflection hacks.

Description

  • Refactored scan target preparation in pkg/rain/model.go into a handler-based system with nullableHandler functions to make adding new nullable types straightforward.
  • Added support for additional nullable pointer primitives including signed/unsigned integers and floats, plus preserved string/bool/time handling and defensive checks for unsigned negatives.
  • Added scanner-aware behavior so fields implementing sql.Scanner (and valuer-aware types) can be scanned directly into custom value objects when practical.
  • Improved embedded struct support by recognising anonymous pointer embeds in metadata and auto-allocating intermediate embedded pointer values during scan traversal via fieldByIndexAlloc and embeddedStructType helpers.
  • Preserved discard behavior for unknown columns and added explicit tests that document unknown-column discarding and missing-field behavior.
  • Added tests in pkg/rain/model_internal_test.go exercising expanded nullable primitives, custom sql.Scanner types, embedded struct allocation/scan, unknown columns, and a clear error path for unsupported nullable types.

Testing

  • Ran go test ./pkg/rain -run TestScanRows -count=1 and the targeted scan tests passed.
  • Ran repository checks make fmt and make lint where make lint reported 0 issues.
  • Ran the full test suite with make test and all packages passed.

Codex Task

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Mar 29, 2026

Greptile Summary

This PR refactors the row-scan pipeline in pkg/rain/model.go from a monolithic switch into a composable handler-based system (nullableHandler functions), adds auto-allocation of intermediate embedded pointer fields (fieldByIndexAlloc / embeddedStructType), and extends nullable primitive support to all signed/unsigned integer widths and float types. It also adds a custom-scanner dispatch path (scannerTarget) and backs everything with a new internal test file covering the new scenarios.

Key findings:

  • P1 – Scanner-only pointer types fail at runtime: scannerTarget requires a pointer field's type to implement both sql.Scanner and driver.Valuer before routing through the custom-scan path. Types with only a Scan method (no Value()) fall through all handlers and return an \"unsupported nullable field type\" error at scan time, defeating the stated goal of making any sql.Scanner-implementing type scannable.
  • P2 – Redundant inner Implements check: Inside the fieldType.Implements(scannerType) && fieldType.Implements(valuerType) block, receiver.Type().Implements(scannerType) evaluates the same condition and is always true; the inner if is dead code.
  • P2 – Per-call map allocations in hot path: nullableSignedIntTarget and nullableUnsignedIntTarget allocate a map[reflect.Kind]bool on every call; a switch statement would avoid the allocation entirely.

Confidence Score: 4/5

Safe to merge after fixing the scanner-only pointer type gap in scannerTarget, which causes a runtime error for valid sql.Scanner implementations without driver.Valuer.

One P1 logic bug: pointer fields implementing sql.Scanner but not driver.Valuer are silently unsupported and return a runtime error. The rest of the changes are well-structured and well-tested. P2 items (redundant inner check, per-call map allocations) are non-blocking style improvements.

pkg/rain/model.go — specifically the scannerTarget function's first pointer-case branch (lines 264–273).

Important Files Changed

Filename Overview
pkg/rain/model.go Core model scanning refactored into handler-based system; adds embedded pointer alloc, scanner-aware dispatch, and broader nullable primitive support — but scannerTarget silently drops pointer fields that implement sql.Scanner without also implementing driver.Valuer, plus a redundant inner Implements check.
pkg/rain/model_internal_test.go New internal test file with three well-structured tests covering expanded nullable types, embedded struct allocation, unknown-column discarding, and the unsupported-type error path; all test fixtures use SQLite in-memory DBs via t.TempDir().

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[prepareScanTarget field] --> B{scannerTarget?}
    B -->|non-pointer| C{field.Addr implements\nsql.Scanner?}
    C -->|yes| D[return field.Addr as target]
    C -->|no| E[return nil, false]
    B -->|pointer| F{fieldType implements\nsql.Scanner AND\ndriver.Valuer?}
    F -->|yes| G[reflect.New elem\nset field on finalize]
    F -->|no| H{fieldType.Elem implements\nsql.Scanner?}
    H -->|yes| I[reflect.New elem\nset field on finalize]
    H -->|no| J[return nil, false]
    B -->|false| K{field.Kind == Pointer?}
    K -->|no, addressable| L[return field.Addr]
    K -->|yes| M[nullablePrimitiveHandlers loop]
    M --> N{string / int / uint\n/ float / bool / time?}
    N -->|matched| O[sql.Null holder\n+ finalize closure]
    N -->|none match| P[error: unsupported\nnullable field type]

    style F fill:#f99,stroke:#c00
    style J fill:#f99,stroke:#c00
Loading
Prompt To Fix All With AI
This is a comment left during a code review.
Path: pkg/rain/model.go
Line: 264-273

Comment:
**Scanner-only pointer types silently unsupported**

The first branch inside the pointer case requires that `fieldType` implements *both* `scannerInterface` and `valuerInterface` before handling it. Any `*T` type that implements `sql.Scanner` (pointer receiver) but does **not** implement `driver.Valuer` will fall through this block.

The second branch (`fieldType.Elem().Implements(scannerType)`) only rescues types where `T` — not `*T` — has a value-receiver `Scan` method, which is uncommon. So the typical custom scanner type:

```go
type Status string
func (s *Status) Scan(src any) error { ... }  // pointer receiver, no Value()
```

…used as `Status *Status` on a struct will reach `nullablePrimitiveHandlers()`, find no match, and return `"rain: unsupported nullable field type *Status"` — silently failing at runtime despite the type correctly implementing the standard `sql.Scanner` interface.

The `driver.Valuer` requirement is not necessary for scan-path correctness and should be removed from the guard. The same `receiver.Interface()`/`field.Set(receiver)` pattern works regardless of whether the type also satisfies `Valuer`:

```go
	if fieldType.Implements(scannerType) {
		receiver := reflect.New(fieldType.Elem())
		if receiver.Type().Implements(scannerType) {
			return receiver.Interface(), func() error {
				field.Set(receiver)
				return nil
			}, true
		}
	}
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: pkg/rain/model.go
Line: 265-272

Comment:
**Inner `Implements` check is always true (dead branch)**

When the outer condition on line 265 is satisfied — `fieldType.Implements(scannerType)` is `true``receiver` is created as `reflect.New(fieldType.Elem())`, whose type is exactly `fieldType` (i.e. `*T`). Therefore `receiver.Type().Implements(scannerType)` evaluates the same expression as the outer guard and is always `true` inside this block.

The inner `if` guard on line 267 can never be `false`, so no code path exists where execution would reach the closing `}` of the outer block without having returned. This should be simplified:

```go
	if fieldType.Implements(scannerType) && fieldType.Implements(valuerType) {
		receiver := reflect.New(fieldType.Elem())
		return receiver.Interface(), func() error {
			field.Set(receiver)
			return nil
		}, true
	}
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: pkg/rain/model.go
Line: 305-313

Comment:
**Map allocated on every call in hot scan path**

`nullableSignedIntTarget` and `nullableUnsignedIntTarget` both construct a fresh `map[reflect.Kind]bool` on every invocation. These functions are called once per column per scanned row, so in high-throughput scenarios this adds heap pressure without any benefit.

Consider promoting them to package-level variables, or replace the map lookup with a `switch` statement which requires no allocation:

```go
func nullableSignedIntTarget(field reflect.Value) (any, func() error, bool) {
	elemType := field.Type().Elem()
	switch elemType.Kind() {
	case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
	default:
		return nil, nil, false
	}
	// ...
}
```

The same applies to the `unsigned` map in `nullableUnsignedIntTarget` (lines 331–337).

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "feat(rain): expand model scanning type s..." | Re-trigger Greptile

Comment thread pkg/rain/model.go
Comment on lines +264 to +273
fieldType := field.Type()
if fieldType.Implements(scannerType) && fieldType.Implements(valuerType) {
receiver := reflect.New(fieldType.Elem())
if receiver.Type().Implements(scannerType) {
return receiver.Interface(), func() error {
field.Set(receiver)
return nil
}
value := holder.V
field.Set(reflect.ValueOf(&value))
}, true
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Scanner-only pointer types silently unsupported

The first branch inside the pointer case requires that fieldType implements both scannerInterface and valuerInterface before handling it. Any *T type that implements sql.Scanner (pointer receiver) but does not implement driver.Valuer will fall through this block.

The second branch (fieldType.Elem().Implements(scannerType)) only rescues types where T — not *T — has a value-receiver Scan method, which is uncommon. So the typical custom scanner type:

type Status string
func (s *Status) Scan(src any) error { ... }  // pointer receiver, no Value()

…used as Status *Status on a struct will reach nullablePrimitiveHandlers(), find no match, and return "rain: unsupported nullable field type *Status" — silently failing at runtime despite the type correctly implementing the standard sql.Scanner interface.

The driver.Valuer requirement is not necessary for scan-path correctness and should be removed from the guard. The same receiver.Interface()/field.Set(receiver) pattern works regardless of whether the type also satisfies Valuer:

	if fieldType.Implements(scannerType) {
		receiver := reflect.New(fieldType.Elem())
		if receiver.Type().Implements(scannerType) {
			return receiver.Interface(), func() error {
				field.Set(receiver)
				return nil
			}, true
		}
	}
Prompt To Fix With AI
This is a comment left during a code review.
Path: pkg/rain/model.go
Line: 264-273

Comment:
**Scanner-only pointer types silently unsupported**

The first branch inside the pointer case requires that `fieldType` implements *both* `scannerInterface` and `valuerInterface` before handling it. Any `*T` type that implements `sql.Scanner` (pointer receiver) but does **not** implement `driver.Valuer` will fall through this block.

The second branch (`fieldType.Elem().Implements(scannerType)`) only rescues types where `T` — not `*T` — has a value-receiver `Scan` method, which is uncommon. So the typical custom scanner type:

```go
type Status string
func (s *Status) Scan(src any) error { ... }  // pointer receiver, no Value()
```

…used as `Status *Status` on a struct will reach `nullablePrimitiveHandlers()`, find no match, and return `"rain: unsupported nullable field type *Status"` — silently failing at runtime despite the type correctly implementing the standard `sql.Scanner` interface.

The `driver.Valuer` requirement is not necessary for scan-path correctness and should be removed from the guard. The same `receiver.Interface()`/`field.Set(receiver)` pattern works regardless of whether the type also satisfies `Valuer`:

```go
	if fieldType.Implements(scannerType) {
		receiver := reflect.New(fieldType.Elem())
		if receiver.Type().Implements(scannerType) {
			return receiver.Interface(), func() error {
				field.Set(receiver)
				return nil
			}, true
		}
	}
```

How can I resolve this? If you propose a fix, please make it concise.

Comment thread pkg/rain/model.go Outdated
Comment on lines +265 to +272
if fieldType.Implements(scannerType) && fieldType.Implements(valuerType) {
receiver := reflect.New(fieldType.Elem())
if receiver.Type().Implements(scannerType) {
return receiver.Interface(), func() error {
field.Set(receiver)
return nil
}
value := holder.V
field.Set(reflect.ValueOf(&value))
}, true
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Inner Implements check is always true (dead branch)

When the outer condition on line 265 is satisfied — fieldType.Implements(scannerType) is truereceiver is created as reflect.New(fieldType.Elem()), whose type is exactly fieldType (i.e. *T). Therefore receiver.Type().Implements(scannerType) evaluates the same expression as the outer guard and is always true inside this block.

The inner if guard on line 267 can never be false, so no code path exists where execution would reach the closing } of the outer block without having returned. This should be simplified:

	if fieldType.Implements(scannerType) && fieldType.Implements(valuerType) {
		receiver := reflect.New(fieldType.Elem())
		return receiver.Interface(), func() error {
			field.Set(receiver)
			return nil
		}, true
	}
Prompt To Fix With AI
This is a comment left during a code review.
Path: pkg/rain/model.go
Line: 265-272

Comment:
**Inner `Implements` check is always true (dead branch)**

When the outer condition on line 265 is satisfied — `fieldType.Implements(scannerType)` is `true``receiver` is created as `reflect.New(fieldType.Elem())`, whose type is exactly `fieldType` (i.e. `*T`). Therefore `receiver.Type().Implements(scannerType)` evaluates the same expression as the outer guard and is always `true` inside this block.

The inner `if` guard on line 267 can never be `false`, so no code path exists where execution would reach the closing `}` of the outer block without having returned. This should be simplified:

```go
	if fieldType.Implements(scannerType) && fieldType.Implements(valuerType) {
		receiver := reflect.New(fieldType.Elem())
		return receiver.Interface(), func() error {
			field.Set(receiver)
			return nil
		}, true
	}
```

How can I resolve this? If you propose a fix, please make it concise.

Comment thread pkg/rain/model.go Outdated
Comment on lines +305 to +313
signed := map[reflect.Kind]bool{
reflect.Int: true,
reflect.Int8: true,
reflect.Int16: true,
reflect.Int32: true,
reflect.Int64: true,
}
if !signed[elemType.Kind()] {
return nil, nil, false
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Map allocated on every call in hot scan path

nullableSignedIntTarget and nullableUnsignedIntTarget both construct a fresh map[reflect.Kind]bool on every invocation. These functions are called once per column per scanned row, so in high-throughput scenarios this adds heap pressure without any benefit.

Consider promoting them to package-level variables, or replace the map lookup with a switch statement which requires no allocation:

func nullableSignedIntTarget(field reflect.Value) (any, func() error, bool) {
	elemType := field.Type().Elem()
	switch elemType.Kind() {
	case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
	default:
		return nil, nil, false
	}
	// ...
}

The same applies to the unsigned map in nullableUnsignedIntTarget (lines 331–337).

Prompt To Fix With AI
This is a comment left during a code review.
Path: pkg/rain/model.go
Line: 305-313

Comment:
**Map allocated on every call in hot scan path**

`nullableSignedIntTarget` and `nullableUnsignedIntTarget` both construct a fresh `map[reflect.Kind]bool` on every invocation. These functions are called once per column per scanned row, so in high-throughput scenarios this adds heap pressure without any benefit.

Consider promoting them to package-level variables, or replace the map lookup with a `switch` statement which requires no allocation:

```go
func nullableSignedIntTarget(field reflect.Value) (any, func() error, bool) {
	elemType := field.Type().Elem()
	switch elemType.Kind() {
	case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
	default:
		return nil, nil, false
	}
	// ...
}
```

The same applies to the `unsigned` map in `nullableUnsignedIntTarget` (lines 331–337).

How can I resolve this? If you propose a fix, please make it concise.

@cungminh2710 cungminh2710 merged commit 36d828c into main Mar 29, 2026
3 checks passed
@cungminh2710 cungminh2710 deleted the codex/linear-mention-qc-230-expand-model-scanning-and-custom-typ branch March 29, 2026 04:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant