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
|
// 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
//
// 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))
// }
package oblast // import "go.xyrillian.de/oblast"
import (
"database/sql"
"fmt"
"reflect"
)
// 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 }
}
// 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
}
// 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.
//
// For the purpose of loading and storing records (i.e. instances of type R) into the database,
// this function establishes a mapping between fields of type R and database columns by inspecting the "db" tag.
// For example:
//
// type MyRecord struct {
// ID int64 `db:"record_id,auto"`
// Foo string `db:"foo"`
// Bar string
// Cache map[string]any `db:"-"`
// action func()
// }
//
// In this type:
// - The fields "ID" and "Foo" correspond to the database columns "record_id" and "foo" because of the declaration in the "db" tag.
// - The field "Bar" corresponds to the database column "Bar" because, when no "db" tag is given, the column name is set equal to the field name.
// - The field "Cache" is not mapped to any database column because it is declared with a "db" tag of "-". Loads and stores will ignore it.
// - The field "action" is private, so loads and stores will ignore it, too.
//
// Besides the declaration of a column name, the following extra tags are understood (as a comma-separated list following the column name):
// - "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.
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
}
|