Column Types
Type Mapping
Scalar Types
| Proto Type | Go Type | SQL Type | Column Constructor |
|---|---|---|---|
int32 | int32 | INTEGER | IntegerColumn |
int64 | int64 | BIGINT | BigIntColumn |
uint32 | uint32 | INTEGER | IntegerColumn |
uint64 | uint64 | BIGINT | BigIntColumn |
float | float32 | REAL | RealColumn |
double | float64 | DOUBLE PRECISION | DoubleColumn |
bool | bool | BOOLEAN | BooleanColumn |
string | string | TEXT | TextColumn |
bytes | []byte | BYTEA | ByteaColumn |
Well-Known Types
| Proto Type | Go Type | SQL Type | Column Constructor |
|---|---|---|---|
google.protobuf.Timestamp | time.Time | TIMESTAMPTZ | TimestamptzColumn |
google.protobuf.Duration | time.Duration | INTERVAL | IntervalColumn |
google.protobuf.Struct | json.RawMessage | JSONB | JSONBColumn |
Wrapper Types (Nullable)
Wrapper types map to nullable columns:
| Proto Type | Go Type | SQL Type | Column Constructor |
|---|---|---|---|
google.protobuf.Int32Value | *int32 | INTEGER NULL | NullIntegerColumn |
google.protobuf.Int64Value | *int64 | BIGINT NULL | NullBigIntColumn |
google.protobuf.UInt32Value | *uint32 | INTEGER NULL | NullIntegerColumn |
google.protobuf.UInt64Value | *uint64 | BIGINT NULL | NullBigIntColumn |
google.protobuf.FloatValue | *float32 | REAL NULL | NullRealColumn |
google.protobuf.DoubleValue | *float64 | DOUBLE PRECISION NULL | NullDoubleColumn |
google.protobuf.BoolValue | *bool | BOOLEAN NULL | NullBooleanColumn |
google.protobuf.StringValue | *string | TEXT NULL | NullTextColumn |
google.protobuf.BytesValue | []byte | BYTEA NULL | NullByteaColumn |
Array Types (repeated)
Repeated scalar fields map to PostgreSQL array columns:
| Proto Type | Go Type | SQL Type | Column Constructor |
|---|---|---|---|
repeated string | []string | TEXT[] | TextArrayColumn |
repeated int32 | []int32 | INTEGER[] | IntegerArrayColumn |
repeated int64 | []int64 | BIGINT[] | BigIntArrayColumn |
repeated bool | []bool | BOOLEAN[] | BooleanArrayColumn |
repeated float | []float32 | REAL[] | RealArrayColumn |
repeated double | []float64 | DOUBLE PRECISION[] | DoublePrecisionArrayColumn |
repeated bytes | [][]byte | BYTEA[] | ByteaArrayColumn |
message Product {
repeated string tags = 5; // → TEXT[] NOT NULL
repeated int64 category_ids = 6; // → BIGINT[] NOT NULL
}
Enum Types
Protobuf enum fields are stored as TEXT in PostgreSQL. Ratel automatically
converts enum values to their string names for storage and back to enum values
when reading.
| Proto Type | Scanner Type | SQL Type | Column Constructor |
|---|---|---|---|
enum | string | TEXT | TextColumn |
enum OrderStatus {
ORDER_STATUS_UNSPECIFIED = 0;
ORDER_STATUS_NEW = 1;
ORDER_STATUS_PAID = 2;
}
message Order {
OrderStatus status = 3; // → TEXT NOT NULL, Scanner field: string
}
The generated IntoPlain() converts enum to string (pb.Status.String()),
and IntoPb() converts back (OrderStatus_value[p.Status]).
Unknown string values map to the zero enum value (typically UNSPECIFIED).
No enum_as_string annotation is needed — protoc-gen-ratel enables this
automatically for all enum fields in ratel tables.
Oneof (Embedded)
Protobuf oneof fields with (goplain.oneof).embed = true are flattened into
the Scanner struct. Each variant becomes a separate column, plus a discriminator
_case column.
For message variants, use (goplain.field).serialize = true to store them as
JSONB (serialized via protojson.Marshal/protojson.Unmarshal). All oneof
columns are nullable since only one variant is active at a time.
message CreateAction {
option (goplain.message).generate = true;
string created_name = 1;
}
message DeleteAction {
option (goplain.message).generate = true;
int64 deleted_id = 1;
string reason = 2;
}
message AuditLog {
option (goplain.message).generate = true;
option (ratel.table) = { generate: true, table_name: "audit_logs" };
int64 id = 1;
string action = 2;
oneof detail {
option (goplain.oneof).embed = true;
CreateAction create_action = 10 [(goplain.field).serialize = true];
DeleteAction delete_action = 11 [(goplain.field).serialize = true];
}
}
Generated columns:
| Column | SQL Type | Scanner Type | Notes |
|---|---|---|---|
create_action_create_action | JSONB NULL | []byte | Serialized as JSON via protojson |
delete_action_delete_action | JSONB NULL | []byte | Serialized as JSON via protojson |
detail_case | TEXT | string | "create_action" or "delete_action" |
The generated IntoPlain() serializes the active variant and sets DetailCase.
IntoPb() deserializes based on DetailCase and reconstructs the oneof wrapper.
Inactive variants produce []byte{} (empty, not nil) in the Scanner.
Serial Types
Primary key fields with integer types automatically use serial variants:
| Proto Type | With primary_key: true | SQL Type |
|---|---|---|
int32 | Yes | SERIAL |
int64 | Yes | BIGSERIAL |
Nullability
Regular proto3 fields generate NOT NULL columns. A column is nullable only when:
- The field uses a wrapper type (
Int64Value,StringValue, etc.) - The field uses proto3
optionalkeyword
message User {
// NOT NULL — regular proto3 field
string email = 1;
// NULL — wrapper type
google.protobuf.StringValue bio = 2;
// NULL — optional keyword
optional string nickname = 3;
}
Generated DDL:
"email" text NOT NULL,
"bio" text NULL,
"nickname" text NULL
Type Aliases
Define custom types that map to scalar types:
// EntityID resolves to int64 → BIGINT
message EntityID {
option (goplain.message) = { generate: true, type_alias: "int64" };
}
message User {
EntityID id = 1 [(ratel.column) = {
constraints: { primary_key: true }
}];
// Generates: BIGSERIAL PRIMARY KEY
}
Type Conversion (Casters)
Protobuf message types don't map directly to Go/SQL types. Ratel uses a caster system to convert between protobuf and Scanner representations at runtime.
How It Works
The conversion pipeline is: Proto Message ↔ Scanner Struct ↔ Database.
For each well-known type, ratel registers a pair of existing casters — functions that convert in both directions. These are called automatically inside IntoPlain() / IntoPb() methods generated by protoc-gen-go-plain.
Built-in Casters
| Proto Type | Scanner Type | ToPlain | ToPb |
|---|---|---|---|
*timestamppb.Timestamp | time.Time | TimestampToTime | TimeToTimestamp |
*timestamppb.Timestamp | *time.Time | TimestampToNullableTime | NullableTimeToTimestamp |
*durationpb.Duration | time.Duration | DurationPbToDuration | DurationToDurationPb |
*durationpb.Duration | *time.Duration | DurationPbToNullableDuration | NullableDurationToDurationPb |
*wrapperspb.Int64Value | int64 | Int64ValueToInt64 | Int64ToInt64Value |
*structpb.Struct | json.RawMessage | StructToRawMessage | RawMessageToStruct |
All functions are in the github.com/yaroher/ratel/pkg/ratelcast package.
Existing vs Parameter Casters
There are two kinds of casters:
-
Existing casters — pre-defined functions registered in
protoc-gen-ratel. When both directions (ToPlain and ToPb) have existing casters, the generatedIntoPlain()/IntoPb()methods call them directly with no extra parameters:// Generated code (no caster parameter needed):
func (pb *User) IntoPlain() *UserScanner {
p.CreatedAt = ratelcast.TimestampToTime(pb.CreatedAt)
// ...
}
var UserConverter = repository.Converter[*UserScanner, *User]{
ToScanner: (*User).IntoPlain,
ToProto: (*UserScanner).IntoPb,
} -
Parameter casters — when a field has a type override without a registered existing caster,
IntoPlain/IntoPbget a*Castersstruct parameter. The generated Converter becomes a function instead of a variable:// Generated code (caster parameter needed):
func (pb *Widget) IntoPlain(c *WidgetScannerCasters) *WidgetScanner { ... }
func WidgetConverterWith(c *WidgetScannerCasters) repository.Converter[*WidgetScanner, *Widget] {
return repository.Converter[*WidgetScanner, *Widget]{
ToScanner: func(pb *Widget) *WidgetScanner { return pb.IntoPlain(c) },
ToProto: func(p *WidgetScanner) *Widget { return p.IntoPb(c) },
}
}
All built-in type overrides (Timestamp, Duration, Int64Value, Struct) have existing casters, so the standard flow produces simple Converter variables with no extra ceremony.
Serialized Fields
For message fields with (goplain.field).serialize = true, the value is stored as []byte in the Scanner using proto.Marshal / proto.Unmarshal. This works for any protobuf message type, including fields inside embedded messages:
message ThemeSettings {
ui.Theme selected_ui_theme = 1 [(goplain.field).serialize = true];
}
Generated Scanner field: SelectedUiTheme []byte, stored as BYTEA in PostgreSQL.
Virtual Columns
Virtual columns are database columns without a corresponding proto field. They exist in the DB schema and Scanner, but not in the protobuf message. Common use cases: password_hash, audit timestamps managed by triggers, computed fields.
Declaration
Define virtual columns in ratel.table.virtual_columns:
message User {
option (ratel.table) = {
generate: true
table_name: "users"
virtual_columns: [
{ sql_name: "password_hash", sql_type: "TEXT" },
{ sql_name: "db_created_at", sql_type: "TIMESTAMPTZ",
constraints: { default_value: "now()" } }
]
};
int64 id = 1;
string email = 2;
}
No need to declare anything in goplain.message — ratel injects virtual fields into the Scanner automatically.
What Gets Generated
Virtual columns are full participants in the generated code:
| Feature | Included | Notes |
|---|---|---|
| Scanner struct field | Yes | PasswordHash string |
With* chainable setter | Yes | scanner.WithPasswordHash(v) |
| Column constant | Yes | UserColumnPasswordHash |
| Table struct accessor | Yes | Users.PasswordHash |
| DDL (CREATE TABLE) | Yes | With type, constraints |
GetTarget / GetSetter / GetValue | Yes | |
AllSetters() | Yes | |
IntoPlain() / IntoPb() | Skipped | No proto field to convert |
Usage
// Convert proto to scanner, set virtual field, insert
scanner := user.IntoPlain().WithPasswordHash(hashedPassword)
insert := tbl.Insert().From(scanner.AllSetters()...)
// Or set after conversion
scanner := user.IntoPlain()
scanner.PasswordHash = hashedPassword
SQL Type Mapping
Virtual column sql_type maps to Scanner Go type:
| SQL Type | Go Type in Scanner |
|---|---|
TEXT, VARCHAR | string |
BIGINT, INT8 | int64 |
INTEGER, INT4 | int32 |
BOOLEAN, BOOL | bool |
REAL, FLOAT4 | float32 |
DOUBLE PRECISION | float64 |
BYTEA | []byte |
Everything else (TIMESTAMPTZ, JSONB, UUID, ...) | string |
AllSetters
Every Scanner gets an AllSetters() method that returns all column values as setters, ready for Insert().From() or Update().Set():
// Instead of manually listing every field:
insert := tbl.Insert().From(
tbl.Id.Set(id),
tbl.Email.Set(email),
tbl.Name.Set(name),
)
// Use AllSetters:
insert := tbl.Insert().From(scanner.AllSetters()...)
AllSetters() includes all columns — both regular and virtual. It returns []set.ValueSetter[ColumnAlias].
Available Column Constructors (Go)
// Integer types
schema.SmallIntColumn[C](alias, opts...)
schema.IntegerColumn[C](alias, opts...)
schema.BigIntColumn[C](alias, opts...)
schema.SerialColumn[C](alias, opts...)
schema.SmallSerialColumn[C](alias, opts...)
schema.BigSerialColumn[C](alias, opts...)
// Floating point
schema.RealColumn[C](alias, opts...)
schema.DoubleColumn[C](alias, opts...)
schema.NumericColumn[C](alias, precision, scale, opts...)
// Text
schema.TextColumn[C](alias, opts...)
schema.VarcharColumn[C](alias, length, opts...)
schema.CharColumn[C](alias, length, opts...)
// Date/Time
schema.DateColumn[C](alias, opts...)
schema.TimeColumn[C](alias, opts...)
schema.TimestampColumn[C](alias, opts...)
schema.TimestamptzColumn[C](alias, opts...)
// Other
schema.BooleanColumn[C](alias, opts...)
schema.UUIDColumn[C](alias, opts...)
schema.JSONColumn[C](alias, opts...)
schema.JSONBColumn[C](alias, opts...)
schema.ByteaColumn[C](alias, opts...)
Every type has a Null variant (e.g., NullBigIntColumn) for nullable columns.