aboutsummaryrefslogtreecommitdiff
path: root/oblast/oblast.go
diff options
context:
space:
mode:
Diffstat (limited to 'oblast/oblast.go')
-rw-r--r--oblast/oblast.go185
1 files changed, 185 insertions, 0 deletions
diff --git a/oblast/oblast.go b/oblast/oblast.go
new file mode 100644
index 0000000..57d0c63
--- /dev/null
+++ b/oblast/oblast.go
@@ -0,0 +1,185 @@
+// 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 *gsql.DB) error {
+// newEntry := LogEntry{
+// CreatedAt: time.Now(),
+// Message: "Hello World.",
+// }
+// err := logEntryStore.Insert(dbh, &newEntry)
+// if err != nil {
+// return err
+// }
+// fmt.Printf("created log entry %d", newEntry.ID)
+//
+// allEntries, err := logEntryStore.SelectWhere(dbh, `created_at < NOW()`)
+// if err != nil {
+// return err
+// }
+// fmt.Printf("there are %d log entries so far", len(allEntries))
+// }
+//
+// In this example, [*gsql.DB] is a thin wrapper around [*sql.DB], which can be obtained with the [gsql.NewDB] function.
+// A [*gsql.DB] can be used in the same way as an [*sql.DB], but if Oblast is only to be used for specific functions,
+// then individual [*sql.Conn] or [*sql.Tx] instances can also be wrapped with the [gsql.NewConn] and [gsql.NewTx] functions.
+//
+// The gsql package serves as an abstraction around different database driver libraries,
+// allowing Oblast to also be used with different database drivers such as pgx (see documentation in package gsql for details).
+//
+// # 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"
+
+ "go.xyrillian.de/gg/gsql"
+)
+
+var (
+ // the following types appear in docstring links
+ _ sql.Scanner = nil
+ _ driver.NamedValueChecker = nil
+ _ *gsql.DB = 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 }
+}
+
+// ReadOnly is a PlanOption that disables all write operations for the resulting [Store] type
+// (i.e., [Store.Insert], [Store.Update], [Store.Upsert] and [Store.Delete]).
+// Besides read-only tables (i.e. tables where the current user lacks write permissions),
+// this is useful for record types that only model a few columns of a table and which,
+// when used in write operations, might result in incomplete records.
+func ReadOnly() PlanOption {
+ return func(opts *planOpts) { opts.ReadOnly = true }
+}
+
+// Store 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 {
+ plan plan
+}
+
+// NewStore initializes a store for record type R.
+// Returns an error if R is not a struct type.
+//
+// In most situations, the intended usage pattern is to call NewStore (or [MustNewStore]) once per record type,
+// and hold the result in a global variable.
+//
+// When dealing with private one-off record types that are declared within the function or method using them,
+// NewStore (or [MustNewStore]) may also be called once per function call.
+// NewStore will internally cache its results and return a cheap copy on subsequent calls with the same arguments,
+// only incurring the cost of a read lock on a mutex.
+func NewStore[R any](dialect Dialect, opts ...PlanOption) (Store[R], error) {
+ plan, err := getOrBuildPlan(reflect.TypeFor[R](), dialect, collectPlanOptions(opts))
+ if err != nil {
+ var zero R
+ return Store[R]{}, fmt.Errorf("cannot use type %T for queries: %w", zero, err)
+ }
+ return Store[R]{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
+}