aboutsummaryrefslogtreecommitdiff
path: root/oblast.go
blob: 15f840a71326369607da93b18aa3f68ca1abf746 (plain)
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
// 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"
	"reflect"

	"go.xyrillian.de/oblast/internal"
)

// PlanOption is an option that can be given to NewStore() to influence query planning for a certain type of record.
type PlanOption func(*internal.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 *internal.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 *internal.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    internal.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 internal.PlanOpts
	for _, opt := range opts {
		opt(&popts)
	}
	plan, err := internal.BuildPlan(reflect.TypeFor[R](), dialect, popts)
	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
}