1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
|
// SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net>
// SPDX-License-Identifier: Apache-2.0
// Package oblast is an ORM library for Go, focusing specifically on just the loading and storing of records in the most efficient manner possible.
// No utilities are provided for generating DDL or managing schema migrations, or for building complex OLAP queries.
//
// # Usage pattern
//
// Oblast can load or store any struct type by matching individual fields to column names (on load) or query arguments (on store).
// Struct types that are suitable for this kind of mapping are called "record types" throughout this package documentation.
//
// To use this library, first declare a record type, and create a [Store] for it once to analyze the type and prepare the respective OLTP queries:
//
// type LogEntry struct {
// ID int64 `db:"id,auto"`
// CreatedAt time.Time `db:"created_at"`
// Message string `db:"message"`
// }
// var logEntryStore = oblast.NewStore[LogEntry](
// oblast.PostgresDialect(),
// oblast.TableNameIs("log_entries"),
// oblast.PrimaryKeyIs("id"),
// )
//
// Then use it many times to perform load and store operations:
//
// func doStuff(db *sql.DB) error {
// newEntry := LogEntry{
// CreatedAt: time.Now(),
// Message: "Hello World.",
// }
// err := logEntryStore.Insert(db, &newEntry)
// if err != nil {
// return err
// }
// fmt.Printf("created log entry %d", newEntry.ID)
//
// allEntries, err := logEntryStore.SelectWhere(db, `created_at < NOW()`)
// if err != nil {
// return err
// }
// fmt.Printf("there are %d log entries so far", len(allEntries))
// }
//
// # Mapping rules for record types
//
// If the database column has a different name (or casing, e.g. "id" vs. "ID") than the field name, provide it in the field tag "db".
// The field tag may also contain additional options, separated from the column name by commas.
// To have Oblast ignore a field, either make it private or declare its column name as "-".
// For example:
//
// type Example struct {
// FirstValue string `db:"first_value"` // maps to DB column "first_value"
// SecondValue string // maps to DB column "SecondValue"
// ThirdValue string `db:"third_value,auto"` // maps to DB column "third_value" with "auto" option
// FourthValue string `db:",auto"` // maps to DB column "FourthValue" with "auto" option
// Cache map[string]any `db:"-"` // ignored by Oblast because of column name "-"
// action func() // ignored by Oblast because field is private
// }
//
// The following field options are understood:
// - "auto": During [Store.Insert], do not store this field's value. Instead, the database will auto-generate a value, which will be read back into the record. In SQL dialects that use [sql.Result.LastInsertId] for this (as opposed to a RETURNING clause), only at most one field per record type may have this option, and it must be of an integer type.
//
// It is possible to place mapped fields within sub-structs, including within embedded types.
// This is useful e.g. to avoid code duplication for database columns that are repeated across multiple types:
//
// type Timestamps struct {
// CreatedAt time.Time `db:"created_at"`
// UpdatedAt *time.Time `db:"updated_at"`
// DeletedAt *time.Time `db:"deleted_at"`
// }
//
// type FooRecord struct {
// ID int64 `db:"id,auto"`
// Name string `db:"name"`
// Timestamps Timestamps
// }
// // ... and other struct types that use type Timestamps ...
//
// This behavior may be undesirable on custom struct types that implement [sql.Scanner] and/or [driver.Valuer], or are understood by a [driver.NamedValueChecker] set up by your SQL driver.
// To keep Oblast from recursing into struct types and mapping their fields, provide an explicit `db:"..."` tag on them:
//
// type GeoPoint struct {
// Longitude, Latitude int
// }
// func (p *GeoPoint) Scan(src any) error {...}
// func (p GeoPoint) Value() (driver.Value, error) {...}
//
// type Event struct {
// ID int64 `db:",auto"`
// Description string
// Time time.Time
// // explicit tag ensures that Location.Longitude and Location.Latitude are not mapped individually
// Location GeoPoint `db:"Location"`
// }
package oblast // import "go.xyrillian.de/oblast"
import (
"database/sql"
"database/sql/driver"
"fmt"
"reflect"
)
var (
// the following types appear in docstring links
_ sql.Scanner = nil
_ driver.NamedValueChecker = nil
)
// PlanOption is an option that can be given to NewStore() to influence query planning for a certain type of record.
type PlanOption func(*planOpts)
// TableNameIs is a PlanOption for record types that correspond to exactly one database table (as opposed to a join of multiple tables).
// This option is required to enable any of the methods of [Store] that use partially or fully auto-generated query strings.
func TableNameIs(name string) PlanOption {
return func(opts *planOpts) { opts.TableName = name }
}
// PrimaryKeyIs is a PlanOption for record types that correspond to a database table with a primary key.
// This option is required to enable use of the [Store.Update] and [Store.Delete] methods.
func PrimaryKeyIs(columnNames ...string) PlanOption {
return func(opts *planOpts) { opts.PrimaryKeyColumnNames = columnNames }
}
// StructTagKeyIs is a PlanOption for record types that allows renaming the struct tag key that Oblast inspects from its default value of "db".
// For example, providing StructTagKeyIs("oblast") means that a struct tag like `db:",auto"` must be written as `oblast:",auto"` instead.
//
// This is useful when migrating from or to another ORM library that uses the same `db:"..."` tag as Oblast, but with conflicting semantics.
func StructTagKeyIs(key string) PlanOption {
return func(opts *planOpts) { opts.StructTagKey = key }
}
// Handle is an interface for functions providing direct DB access.
// It covers methods provided by both *sql.DB and *sql.Tx, thus allowing functions using it to be used both within and outside of transactions.
type Handle interface {
Exec(query string, args ...any) (sql.Result, error)
Prepare(query string) (*sql.Stmt, error)
Query(query string, args ...any) (*sql.Rows, error)
QueryRow(query string, args ...any) *sql.Row
}
// TODO: investigate if we can extend type Handle to cover types github.com/jackc/pgx.{Conn,Tx}
// - those have all these methods, but with different return types that act mostly in the same way
// - a significant departure is that their Prepare() works wildly differently
// static assertion that the respective types implement the interface
var (
_ Handle = &sql.DB{}
_ Handle = &sql.Tx{}
)
// Store is the main interface of this library.
//
// It holds information on how to read and write data into record type R,
// and can also be used to execute autogenerated queries if the respective [PlanOption] values were provided during [NewStore].
type Store[R any] struct {
dialect Dialect
plan plan
}
// NewStore initializes a store for record type R.
// Returns an error if R is not a struct type.
func NewStore[R any](dialect Dialect, opts ...PlanOption) (Store[R], error) {
var popts planOpts
for _, opt := range opts {
opt(&popts)
}
plan, err := buildPlan(reflect.TypeFor[R](), dialect, popts)
if err != nil {
var zero R
return Store[R]{}, fmt.Errorf("cannot use type %T for queries: %w", zero, err)
}
return Store[R]{dialect, plan}, err
}
// MustNewStore is like [NewStore], but panics on error.
func MustNewStore[R any](dialect Dialect, opts ...PlanOption) Store[R] {
store, err := NewStore[R](dialect, opts...)
if err != nil {
panic(err.Error())
}
return store
}
|