Skip to content

feat(relations): introduce typed relation definitions and select relation loading#9

Merged
cungminh2710 merged 2 commits into
mainfrom
codex/linear-mention-qc-227-introduce-typed-relation-definitions
Mar 29, 2026
Merged

feat(relations): introduce typed relation definitions and select relation loading#9
cungminh2710 merged 2 commits into
mainfrom
codex/linear-mention-qc-227-introduce-typed-relation-definitions

Conversation

@cungminh2710
Copy link
Copy Markdown
Contributor

Motivation

  • Provide first-class relation metadata so schemas can declare belongs_to and has_many relations and the query layer can load related rows without manual join wiring.
  • Keep the API explicit and simple for v1: opt-in relation loading, clear errors for misconfiguration, and predictable scan mapping rules.

Description

  • Add typed relation metadata to schema by introducing RelationType and RelationDef, and storing relations on TableDef with RelationByName lookup.
  • Add TableModel.BelongsTo(...) and TableModel.HasMany(...) with validation and duplicate checks, and preserve relations when aliasing table handles via cloneTableDef.
  • Extend model metadata parsing to recognize rain:"relation:<name>" tags and cache relation-targeted fields alongside db column tags.
  • Add SelectQuery.WithRelations(...) and wire SelectQuery.Scan to perform relation-aware scanning when relation names are requested.
  • Implement relation loading in pkg/rain/relation_loading.go, which: loads related rows after base scan, enforces mapping rules (struct/ptr for belongs_to, slice for has_many), and returns clear errors for unknown or misconfigured relations.
  • Add tests and documentation: schema test for relation registration, integration-style tests for loading belongs_to and has_many, unknown-relation failure case, and an ADR at docs/adr/2026-03-28-typed-relations-design.md describing the v1 design and mapping rules.

Testing

  • Ran make fmt (applied code formatting) and make lint (no lint issues reported). Both succeeded.
  • Ran go test ./pkg/schema ./pkg/rain and full make test; unit and integration tests passed for the modified packages (ok for both packages; overall test run succeeded).

Codex Task

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Mar 29, 2026

Greptile Summary

This PR introduces first-class relation metadata to the schema layer (belongs_to, has_many) and wires it into SelectQuery via WithRelations(...) / Scan, enabling opt-in relation loading without manual join wiring. The schema changes (RelationDef, RelationByName, BelongsTo/HasMany with duplicate/nil guards) and the model-tag parsing (rain:\"relation:<name>\") are clean and consistent with existing patterns. Relation loading in relation_loading.go is the main area of concern.

  • P1 – Silent skip of unknown-relation error on empty result sets: loadRelationsIntoSlice returns nil early when the base query returns 0 rows, bypassing the relation-name validation loop entirely. WithRelations(\"bad_name\") will silently succeed if the base query matches no rows. The existing test only covers the non-empty case.
  • P2 – N+1 query per distinct source key: One SELECT is fired per unique source-key value; acknowledged in the ADR as a v1 trade-off, but a TODO comment at the loop would help future maintainers locate the optimization target.
  • P2 – Missing ok guard in relationElementType and setRelationValue: Both functions access meta.byRelation[name] without checking ok; a nil index would panic if either is ever called outside the currently validated flow.

Confidence Score: 4/5

Safe to merge after fixing the empty-result unknown-relation silent failure; all other findings are style/robustness suggestions.

One P1 defect: WithRelations with an invalid name silently returns nil when the base query yields 0 rows, directly contradicting the stated contract. The schema layer, model parsing, and query builder changes are solid. The two P2 findings are robustness/style and do not block merge, but the P1 should be addressed.

pkg/rain/relation_loading.go — unknown-relation validation bypassed for empty result sets; also two unguarded map lookups that could panic if called outside their validated context.

Important Files Changed

Filename Overview
pkg/rain/relation_loading.go Core relation loading logic; has a P1 bug where unknown-relation validation is skipped for empty result sets, and two missing ok-guards in relationElementType/setRelationValue that could panic if called outside the current validated flow.
pkg/rain/query_internal_test.go Adds belongs_to, has_many, and unknown-relation integration tests; unknown-relation test only covers the non-empty result case, leaving the empty-result silent-skip untested.
pkg/schema/schema.go Adds RelationType, RelationDef, BelongsTo/HasMany registration methods with validation, RelationByName lookup, and preserves relations through cloneTableDef — solid, well-guarded schema layer.
pkg/rain/model.go Adds byRelation map to modelMeta, parses rain:"relation:" struct tags in buildModelMeta, and introduces the relationTagName helper — clean and consistent with existing patterns.
pkg/rain/query.go Adds relationNames field to SelectQuery, WithRelations builder method, and branches Scan to use scanRowsWithRelations when relations are requested — minimal and correct.
pkg/schema/schema_test.go Adds a focused schema-layer test for relation registration metadata — correct and sufficient for the schema layer.
docs/adr/2026-03-28-typed-relations-design.md ADR documenting the v1 relation design — clearly captures context, decisions, mapping rules, and known limitations.

Sequence Diagram

sequenceDiagram
    participant Caller
    participant SelectQuery
    participant scanRowsWithRelations
    participant scanRows
    participant loadRelationsIntoSlice
    participant loadRelation
    participant DB

    Caller->>SelectQuery: .WithRelations("author").Scan(ctx, &dest)
    SelectQuery->>DB: Execute base SELECT query
    DB-->>SelectQuery: *sql.Rows
    SelectQuery->>scanRowsWithRelations: rows, dest
    scanRowsWithRelations->>scanRows: scan base rows into slice
    scanRows-->>scanRowsWithRelations: []ParentRow
    scanRowsWithRelations->>loadRelationsIntoSlice: []ParentRow
    loadRelationsIntoSlice->>loadRelationsIntoSlice: validate relation names exist
    loop for each relation name
        loadRelationsIntoSlice->>loadRelation: parents, RelationDef
        loadRelation->>loadRelation: validateRelationField (all parents)
        loadRelation->>loadRelation: collect unique source key values
        loop for each distinct source key
            loadRelation->>DB: SELECT * FROM target WHERE target_col = key
            DB-->>loadRelation: related rows
        end
        loadRelation->>loadRelation: group related rows by target key
        loadRelation->>loadRelation: setRelationValue on each parent
    end
    loadRelationsIntoSlice-->>scanRowsWithRelations: nil / error
    scanRowsWithRelations-->>Caller: populated dest
Loading
Prompt To Fix All With AI
This is a comment left during a code review.
Path: pkg/rain/relation_loading.go
Line: 62-83

Comment:
**Unknown relation silently ignored for empty result sets**

`loadRelationsIntoSlice` returns `nil` early when `parents.Len() == 0` (line 68–70), which means the relation name validation loop on lines 72–80 is never reached. If the base query returns 0 rows, an invalid relation name like `WithRelations("does_not_exist")` is silently accepted instead of returning the expected `"unknown relation"` error.

The test in `query_internal_test.go` happens to pass only because `alice` and `bob` are always present in the database at the time the unknown-relation assertion runs. A query that returns zero rows (e.g. filtering by a non-existent ID) would skip the error entirely.

The fix is to validate relation names before the early-return guard:

```go
func (q *SelectQuery) loadRelationsIntoSlice(ctx context.Context, parents reflect.Value) error {
	tableSource, ok := q.table.(tableDefSource)
	if !ok {
		return fmt.Errorf("rain: relation loading requires a concrete table source")
	}

	// Validate all relation names first, regardless of result-set size.
	for _, relationName := range q.relationNames {
		if _, exists := tableSource.table.RelationByName(relationName); !exists {
			return fmt.Errorf("rain: unknown relation %q on table %q", relationName, tableSource.table.Name)
		}
	}

	if parents.Len() == 0 {
		return nil
	}

	for _, relationName := range q.relationNames {
		relation, _ := tableSource.table.RelationByName(relationName)
		if err := q.loadRelation(ctx, parents, relation); err != nil {
			return err
		}
	}

	return nil
}
```

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/relation_loading.go
Line: 114-135

Comment:
**N+1 query per distinct source-key value**

The inner loop fires one `SELECT` per entry in `sourceKeys`, so loading a relation across N distinct parent key values issues N round-trips to the database. For a page of 50 posts, that's 50 queries just to load the author.

The ADR acknowledges this as a known v1 trade-off ("batched `IN` loading" is listed as a future optimization), so this is informational rather than blocking. However, it may be worth adding a brief code comment so future maintainers see the intentional constraint and know exactly where to target the optimization:

```go
// TODO: replace per-key queries with a single IN-clause query
// (see docs/adr/2026-03-28-typed-relations-design.md).
for _, sourceKey := range sourceKeys {
```

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/relation_loading.go
Line: 180-198

Comment:
**Missing `ok` guard when indexing `meta.byRelation` in `relationElementType` and `setRelationValue`**

`relationElementType` (line 185) and `setRelationValue` (line 224) both do:

```go
fieldInfo := meta.byRelation[relation.Name]
field := parent.FieldByIndex(fieldInfo.index)
```

without checking the `ok` return value. If `relation.Name` is absent from the map, `fieldInfo.index` is `nil`, and `FieldByIndex(nil)` panics.

In the current call graph this can't happen because `validateRelationField` is always called first for every parent. However, since `relationElementType` is a method on `*SelectQuery`, it could be called independently by future code paths. Adding a guard (matching the pattern already used in `validateRelationField`) would make both functions safe in isolation:

```go
fieldInfo, ok := meta.byRelation[relation.Name]
if !ok {
    return nil, fmt.Errorf("rain: relation %q not found in model metadata", relation.Name)
}
```

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

Reviews (1): Last reviewed commit: "feat(relations): add typed relations and..." | Re-trigger Greptile

Comment on lines +62 to +83
func (q *SelectQuery) loadRelationsIntoSlice(ctx context.Context, parents reflect.Value) error {
tableSource, ok := q.table.(tableDefSource)
if !ok {
return fmt.Errorf("rain: relation loading requires a concrete table source")
}

if parents.Len() == 0 {
return nil
}

for _, relationName := range q.relationNames {
relation, exists := tableSource.table.RelationByName(relationName)
if !exists {
return fmt.Errorf("rain: unknown relation %q on table %q", relationName, tableSource.table.Name)
}
if err := q.loadRelation(ctx, parents, relation); err != nil {
return err
}
}

return nil
}
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 Unknown relation silently ignored for empty result sets

loadRelationsIntoSlice returns nil early when parents.Len() == 0 (line 68–70), which means the relation name validation loop on lines 72–80 is never reached. If the base query returns 0 rows, an invalid relation name like WithRelations("does_not_exist") is silently accepted instead of returning the expected "unknown relation" error.

The test in query_internal_test.go happens to pass only because alice and bob are always present in the database at the time the unknown-relation assertion runs. A query that returns zero rows (e.g. filtering by a non-existent ID) would skip the error entirely.

The fix is to validate relation names before the early-return guard:

func (q *SelectQuery) loadRelationsIntoSlice(ctx context.Context, parents reflect.Value) error {
	tableSource, ok := q.table.(tableDefSource)
	if !ok {
		return fmt.Errorf("rain: relation loading requires a concrete table source")
	}

	// Validate all relation names first, regardless of result-set size.
	for _, relationName := range q.relationNames {
		if _, exists := tableSource.table.RelationByName(relationName); !exists {
			return fmt.Errorf("rain: unknown relation %q on table %q", relationName, tableSource.table.Name)
		}
	}

	if parents.Len() == 0 {
		return nil
	}

	for _, relationName := range q.relationNames {
		relation, _ := tableSource.table.RelationByName(relationName)
		if err := q.loadRelation(ctx, parents, relation); err != nil {
			return err
		}
	}

	return nil
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: pkg/rain/relation_loading.go
Line: 62-83

Comment:
**Unknown relation silently ignored for empty result sets**

`loadRelationsIntoSlice` returns `nil` early when `parents.Len() == 0` (line 68–70), which means the relation name validation loop on lines 72–80 is never reached. If the base query returns 0 rows, an invalid relation name like `WithRelations("does_not_exist")` is silently accepted instead of returning the expected `"unknown relation"` error.

The test in `query_internal_test.go` happens to pass only because `alice` and `bob` are always present in the database at the time the unknown-relation assertion runs. A query that returns zero rows (e.g. filtering by a non-existent ID) would skip the error entirely.

The fix is to validate relation names before the early-return guard:

```go
func (q *SelectQuery) loadRelationsIntoSlice(ctx context.Context, parents reflect.Value) error {
	tableSource, ok := q.table.(tableDefSource)
	if !ok {
		return fmt.Errorf("rain: relation loading requires a concrete table source")
	}

	// Validate all relation names first, regardless of result-set size.
	for _, relationName := range q.relationNames {
		if _, exists := tableSource.table.RelationByName(relationName); !exists {
			return fmt.Errorf("rain: unknown relation %q on table %q", relationName, tableSource.table.Name)
		}
	}

	if parents.Len() == 0 {
		return nil
	}

	for _, relationName := range q.relationNames {
		relation, _ := tableSource.table.RelationByName(relationName)
		if err := q.loadRelation(ctx, parents, relation); err != nil {
			return err
		}
	}

	return nil
}
```

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

Comment on lines +114 to +135
for _, sourceKey := range sourceKeys {
query := &SelectQuery{runner: q.runner, dialect: q.dialect, table: tableDefSource{table: relation.TargetTable}}
relatedRows := reflect.New(reflect.SliceOf(relatedElemType))
if err := query.Where(schema.ComparisonExpr{Left: schema.Ref(relation.TargetColumn), Operator: "=", Right: schema.ValueExpr{Value: sourceKey}}).
Scan(ctx, relatedRows.Interface()); err != nil {
if err == sql.ErrNoRows {
continue
}
return err
}
for rowIdx := 0; rowIdx < relatedRows.Elem().Len(); rowIdx++ {
related := relatedRows.Elem().Index(rowIdx)
targetValue, ok, err := relationColumnValue(related, relation.TargetColumn.Name)
if err != nil {
return err
}
if !ok {
continue
}
relatedByTargetKey[toTypedKey(targetValue)] = append(relatedByTargetKey[toTypedKey(targetValue)], related)
}
}
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 N+1 query per distinct source-key value

The inner loop fires one SELECT per entry in sourceKeys, so loading a relation across N distinct parent key values issues N round-trips to the database. For a page of 50 posts, that's 50 queries just to load the author.

The ADR acknowledges this as a known v1 trade-off ("batched IN loading" is listed as a future optimization), so this is informational rather than blocking. However, it may be worth adding a brief code comment so future maintainers see the intentional constraint and know exactly where to target the optimization:

// TODO: replace per-key queries with a single IN-clause query
// (see docs/adr/2026-03-28-typed-relations-design.md).
for _, sourceKey := range sourceKeys {
Prompt To Fix With AI
This is a comment left during a code review.
Path: pkg/rain/relation_loading.go
Line: 114-135

Comment:
**N+1 query per distinct source-key value**

The inner loop fires one `SELECT` per entry in `sourceKeys`, so loading a relation across N distinct parent key values issues N round-trips to the database. For a page of 50 posts, that's 50 queries just to load the author.

The ADR acknowledges this as a known v1 trade-off ("batched `IN` loading" is listed as a future optimization), so this is informational rather than blocking. However, it may be worth adding a brief code comment so future maintainers see the intentional constraint and know exactly where to target the optimization:

```go
// TODO: replace per-key queries with a single IN-clause query
// (see docs/adr/2026-03-28-typed-relations-design.md).
for _, sourceKey := range sourceKeys {
```

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

Comment on lines +180 to +198
func (q *SelectQuery) relationElementType(parent reflect.Value, relation schema.RelationDef) (reflect.Type, error) {
meta, _, err := lookupModelMeta(parent.Addr().Interface())
if err != nil {
return nil, err
}
fieldInfo := meta.byRelation[relation.Name]
field := parent.FieldByIndex(fieldInfo.index)
switch relation.Type {
case schema.RelationTypeBelongsTo:
if field.Kind() == reflect.Pointer {
return field.Type().Elem(), nil
}
return field.Type(), nil
case schema.RelationTypeHasMany:
return field.Type().Elem(), nil
default:
return nil, fmt.Errorf("rain: unsupported relation type %q", relation.Type)
}
}
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 Missing ok guard when indexing meta.byRelation in relationElementType and setRelationValue

relationElementType (line 185) and setRelationValue (line 224) both do:

fieldInfo := meta.byRelation[relation.Name]
field := parent.FieldByIndex(fieldInfo.index)

without checking the ok return value. If relation.Name is absent from the map, fieldInfo.index is nil, and FieldByIndex(nil) panics.

In the current call graph this can't happen because validateRelationField is always called first for every parent. However, since relationElementType is a method on *SelectQuery, it could be called independently by future code paths. Adding a guard (matching the pattern already used in validateRelationField) would make both functions safe in isolation:

fieldInfo, ok := meta.byRelation[relation.Name]
if !ok {
    return nil, fmt.Errorf("rain: relation %q not found in model metadata", relation.Name)
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: pkg/rain/relation_loading.go
Line: 180-198

Comment:
**Missing `ok` guard when indexing `meta.byRelation` in `relationElementType` and `setRelationValue`**

`relationElementType` (line 185) and `setRelationValue` (line 224) both do:

```go
fieldInfo := meta.byRelation[relation.Name]
field := parent.FieldByIndex(fieldInfo.index)
```

without checking the `ok` return value. If `relation.Name` is absent from the map, `fieldInfo.index` is `nil`, and `FieldByIndex(nil)` panics.

In the current call graph this can't happen because `validateRelationField` is always called first for every parent. However, since `relationElementType` is a method on `*SelectQuery`, it could be called independently by future code paths. Adding a guard (matching the pattern already used in `validateRelationField`) would make both functions safe in isolation:

```go
fieldInfo, ok := meta.byRelation[relation.Name]
if !ok {
    return nil, fmt.Errorf("rain: relation %q not found in model metadata", relation.Name)
}
```

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

@cungminh2710 cungminh2710 merged commit db20ce5 into main Mar 29, 2026
1 check passed
@cungminh2710 cungminh2710 deleted the codex/linear-mention-qc-227-introduce-typed-relation-definitions branch March 29, 2026 00:34
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