exql

package module
v2.0.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Feb 3, 2023 License: MIT Imports: 17 Imported by: 0

README

exql

codecov

Safe, strict and clear ORM for Go

Introduction

exql is a simple ORM library for MySQL, written in Go. It is designed to work at the minimum for real software development. It has a few, limited but enough convenient functionalities of SQL database. We adopted the data mapper model, not the active record. Records in the database are mapped into structs simply. Each model has no state and also no methods to modify itself and sync database records. You need to write bare SQL code for every operation you need except for a few cases.

exql is designed by focusing on safety and clearness in SQL usage. In other words, we never generate any SQL statements that are potentially dangerous or have ambiguous side effects across tables and the database.

It does:

  • make insert/update query from model structs.
  • map rows returned from the database into structs.
  • map joined table into one or more structs.
  • provide a safe syntax for the transaction.
  • provide a framework to build dynamic SQL statements safely.
  • generate model codes automatically from the database.

It DOESN'T

  • make delete/update statements across the table.
  • make unexpectedly slow select queries that don't use correct indices.
  • modify any database settings, schemas and indices.

Table of contents

Usage

Open database connection

package main

import (
	"time"

	"log"

	"github.com/loilo-inc/exql/v2"
)

func OpenDB() exql.DB {
	db, err := exql.Open(&exql.OpenOptions{
		// MySQL url for sql.Open()
		Url: "user:password@tcp(127.0.0.1:3306)/database?charset=utf8mb4&parseTime=True&loc=Local",
		// Max retry count for database connection failure
		MaxRetryCount: 3,
		RetryInterval: 10 * time.Second,
	})
	if err != nil {
		log.Fatalf("open error: %s", err)
		return nil
	}
	return db
}

Code Generation

exql provides an automated code generator of models based on the database schema. This is a typical table schema of MySQL database.

mysql> show columns from users;
+-------+--------------+------+-----+---------+----------------+
| Field | Type         | Null | Key | Default | Extra          |
+-------+--------------+------+-----+---------+----------------+
| id    | int(11)      | NO   | PRI | NULL    | auto_increment |
| name  | varchar(255) | NO   |     | NULL    |                |
| age   | int(11)      | NO   |     | NULL    |                |
+-------+--------------+------+-----+---------+----------------+

To generate model codes, based on the schema, you need to write the code like this:

package main

import (
	"database/sql"
	"log"

	_ "github.com/go-sql-driver/mysql"
	"github.com/loilo-inc/exql/v2"
)

func GenerateModels() {
	db, _ := sql.Open("mysql", "url-for-db")
	gen := exql.NewGenerator(db)
	err := gen.Generate(&exql.GenerateOptions{
		// Directory path for result. Default is `model`
		OutDir: "dist",
		// Package name for models. Default is `model`
		Package: "dist",
		// Exclude table names for generation. Default is []
		Exclude: []string{
			"internal",
		},
	})
	if err != nil {
		log.Fatalf(err.Error())
	}
}

And results are mostly like this:

// This file is generated by exql. DO NOT edit.
package model

type Users struct {
	Id   int64  `exql:"column:id;type:int(11);primary;not null;auto_increment" json:"id"`
	Name string `exql:"column:name;type:varchar(255);not null" json:"name"`
	Age  int64  `exql:"column:age;type:int(11);not null" json:"age"`
}

func (u *Users) TableName() string {
	return UsersTableName
}

type UpdateUsers struct {
	Id   *int64  `exql:"column:id;type:int(11);primary;not null;auto_increment" json:"id"`
	Name *string `exql:"column:name;type:varchar(255);not null" json:"name"`
	Age  *int64  `exql:"column:age;type:int(11);not null" json:"age"`
}

func (u *UpdateUsers) UpdateTableName() string {
	return UsersTableName
}

const UsersTableName = "users"

Users is the destination of the data mapper. It only has value fields and one method, TableName(). This is the implementation of exql.Model that can be passed into data saver. All structs, methods and field tags must be preserved as it is, for internal use. If you want to modify the results, you must run the generator again.

UpdateUsers is a partial structure for the data model. It has identical name fields to Users, but all types are represented as a pointer. It is used to update table columns partially. In other words, it is a designated, typesafe map for the model.

Execute queries

There are several ways to publish SQL statements with exql.

Insert

INSERT query is constructed automatically based on model data and executed without writing the statement. To insert new records into the database, set values to the model and pass it to exql.DB#Insert method.

package main

import (
	"log"

	"github.com/loilo-inc/exql/v2"
	"github.com/loilo-inc/exql/v2/model"
)

func Insert(db exql.DB) {
	// Create a user model
	// Primary key (id) is not needed to set.
	// It will be ignored on building the insert query.
	user := model.Users{
		Name: "Go",
	}
	// You must pass the model as a pointer.
	if result, err := db.Insert(&user); err != nil {
		log.Fatal(err.Error())
	} else {
		insertedId, _ := result.LastInsertId()
		// Inserted id is assigned into the auto-increment field after the insertion,
		// if these field is int64/uint64
		if insertedId != user.Id {
			log.Fatal("never happens")
		}
	}
}

func BulkInsert(db exql.DB) {
	user1 := model.Users{Name: "Go"}
	user2 := model.Users{Name: "Lang"}
	// INSERT INTO users (name) VALUES (?),(?)
	// ["Go", "Lang"]
	if q, err := exql.QueryForBulkInsert(&user1, &user2); err != nil {
		log.Fatal(err)
	} else if _, err := db.Exec(q); err != nil {
		log.Fatal(err)
	}
	// NOTE: unlike a single insertion, bulk insertion doesn't obtain auto-incremented values from results.
}

Update

UPDATE query is constructed automatically based on the model update struct. To avoid unexpected updates to the table, all values are represented by a pointer of data type.

package main

import (
	"log"

	"github.com/loilo-inc/exql/v2"
	"github.com/loilo-inc/exql/v2/model"
)

// Using designated update struct
func UpdateModel(db exql.DB) {
	// UPDATE `users` SET `name` = `GoGo` WHERE `id` = ?
	// [1]
	_, err := db.UpdateModel(&model.UpdateUsers{
		Name: exql.Ptr("GoGo"),
	}, exql.Where("id = ?", 1),
	)
	if err != nil {
		log.Fatal(err)
	}
}

// With table name and key-value pairs
func Update(db exql.DB) {
	// UPDATE `users` SET `name` = `GoGo` WHERE `id` = ?
	// [1]
	_, err := db.Update("users", map[string]any{
		"name": "GoGo",
	}, exql.Where("id = ?", 1))
	if err != nil {
		log.Fatal(err)
	}
}

Delete

DELETE query is published to the table with given conditions. There's no way to construct DELETE query from the model as a security reason.

package main

import (
	"log"

	"github.com/loilo-inc/exql/v2"
)

func Delete(db exql.DB) {
	// DELETE FROM `users` WHERE id = ?
	// [1]
	_, err := db.Delete("users", exql.Where("id = ?", 1))
	if err != nil {
		log.Fatal(err)
	}
}

Other

Other queries should be executed by sql.DB that got from DB.

package main

import (
	"log"

	"github.com/loilo-inc/exql/v2"
)

// To execute other kind of queries, unwrap sql.DB.
func OtherQuery(db exql.DB) {
	// db.DB() returns *sql.DB
	row := db.DB().QueryRow("SELECT COUNT(*) FROM users")
	var count int
	row.Scan(&count)
	log.Printf("%d", count)
}

Transaction

Transaction with BEGIN~COMMIT/ROLLBACK is done by TransactionWithContext. You don't need to call BeginTx and Commit/Rollback manually and all atomic operations are done within a callback.

package main

import (
	"context"
	"database/sql"
	"time"

	"github.com/loilo-inc/exql/v2"
	"github.com/loilo-inc/exql/v2/model"
)

func Transaction(db exql.DB) {
	timeout, _ := context.WithTimeout(context.Background(), 10*time.Second)
	err := db.TransactionWithContext(timeout, &sql.TxOptions{
		Isolation: sql.LevelDefault,
		ReadOnly:  false,
	}, func(tx exql.Tx) error {
		user := model.Users{Name: "go"}
		_, err := tx.Insert(&user)
		return err
	})
	if err != nil {
		// Transaction has been rolled back
	} else {
		// Transaction has been committed
	}
}

Find records

To find records from the database, use Find/FindMany method. It executes the query and maps results into structs correctly.

For simple query
package main

import (
	"log"

	"github.com/loilo-inc/exql/v2"
	"github.com/loilo-inc/exql/v2/model"
	"github.com/loilo-inc/exql/v2/query"
)

func Find(db exql.DB) {
	// Destination model struct
	var user model.Users
	// Pass as a pointer
	err := db.Find(query.Q(`SELECT * FROM users WHERE id = ?`, 1), &user)
	if err != nil {
		log.Fatal(err)
	}
	log.Printf("%d", user.Id) // -> 1
}

func FindMany(db exql.DB) {
	// Destination slice of models.
	// NOTE: It must be the slice of pointers of models.
	var users []*model.Users
	// Passing destination to MapMany().
	// Second argument must be a pointer.
	err := db.FindMany(query.Q(`SELECT * FROM users LIMIT ?`, 5), &users)
	if err != nil {
		log.Fatal(err)
	}
	log.Printf("%d", len(users)) // -> 5
}

For joined table
package main

import (
	"log"

	"github.com/loilo-inc/exql/v2"
	"github.com/loilo-inc/exql/v2/model"
)

/*
user_groups has many users
users belongs to many groups
*/
func MapSerial(db exql.DB) {
	query := `
	SELECT * FROM users
	JOIN group_users ON group_users.user_id = users.id
	JOIN user_groups ON user_groups.id = group_users.id
	WHERE user_groups.name = ?`
	rows, err := db.DB().Query(query, "goland")
	if err != nil {
		log.Fatal(err)
		return
	}
	defer rows.Close()
	serialMapper := exql.NewSerialMapper(func(i int) string {
		// Each column's separator is `id`
		return "id"
	})
	var users []*model.Users
	for rows.Next() {
		var user model.Users
		var groupUsers model.GroupUsers
		var userGroup model.UserGroups
		// Create serial mapper. It will split joined columns by logical tables.
		// In this case, joined table and destination mappings are:
		// |   users   |       group_users        |  user_groups  |
		// + --------- + ------------------------ + ------------- +
		// | id | name | id | user_id |  group_id |  id  |  name  |
		// + --------- + ------------------------ + ------------- +
		// |   &user   |        &groupUsers       |   &userGroup  |
		// + --------- + ------------------------ + ------------- +
		if err := serialMapper.Map(rows, &user, &groupUsers, &userGroup); err != nil {
			log.Fatalf(err.Error())
			return
		}
		users = append(users, &user)
	}
	// enumerate users...
}

For outer-joined table
package main

import (
	"log"

	"github.com/loilo-inc/exql/v2"
	"github.com/loilo-inc/exql/v2/model"
)

func MapSerialOuterJoin(db exql.DB) {
	query := `
	SELECT * FROM users
	LEFT JOIN group_users ON group_users.user_id = users.id
	LEFT JOIN user_groups ON user_groups.id = group_users.id
	WHERE users.id = ?`
	rows, err := db.DB().Query(query, 1)
	if err != nil {
		log.Fatal(err)
		return
	}
	defer rows.Close()
	serialMapper := exql.NewSerialMapper(func(i int) string {
		// Each column's separator is `id`
		return "id"
	})
	var users []*model.Users
	var groups []*model.UserGroups
	for rows.Next() {
		var user model.Users
		var groupUser *model.GroupUsers // Use *GroupUsers/*Group for outer join so that it can be nil
		var group *model.UserGroups     // when the values of outer joined columns are NULL.
		if err := serialMapper.Map(rows, &user, &groupUser, &group); err != nil {
			log.Fatal(err.Error())
			return
		}
		users = append(users, &user)
		groups = append(groups, group) // group = nil when the user does not belong to any group.
	}
	// enumerate users and groups.
}

Use query builder

exql/query package is a low-level API for building complicated SQL statements. See V2 Release Notes for more details.

package main

import (
	"github.com/loilo-inc/exql/v2"
	"github.com/loilo-inc/exql/v2/query"
)

func Query(db exql.DB) {
	q := query.New(
		`SELECT * FROM users WHERE id IN (:?) AND age = ?`,
		query.V(1, 2, 3), 20,
	)
	// SELECT * FROM users WHERE id IN (?,?,?) AND age = ?
	// [1,2,3,20]
	db.Query(q)
}

func QueryBulider(db exql.DB) {
	qb := query.NewBuilder()
	qb.Sprintf("SELECT * FROM %s", "users")
	qb.Query("WHERE id IN (:?) AND age >= ?", query.V(1, 2), 20)
	// SELECT * FROM users WHERE id IN (?,?) AND age >= ?
	// [1,2,20]
	db.Query(qb.Build())
}

func CondBulider(db exql.DB) {
	cond := query.Cond("id = ?", 1)
	cond.And("age >= ?", 20)
	cond.And("name in (:?)", query.V("go", "lang"))
	q := query.New("SELECT * FROM users WHERE :?", cond)
	// SELECT * FROM users WHERE id = ? and age >= ? and name in (?,?)
	// [1, 20, go, lang]
	db.Query(q)
}

License

MIT License / Copyright (c) LoiLo inc.

Documentation

Index

Constants

This section is empty.

Variables

View Source
var ErrRecordNotFound = errors.New("record not found")

Error returned when record not found

Functions

func MapRow

func MapRow(row *sql.Rows, pointerOfStruct interface{}) error

MapRow reads data from single row and maps those columns into destination struct. pointerOfStruct MUST BE a pointer of struct. It closes rows after mapping regardless error occurred.

Example:

var user User
err := exql.MapRow(rows, &user)

func MapRows

func MapRows(rows *sql.Rows, structPtrOrSlicePtr interface{}) error

MapRows reads all data from rows and maps those columns for each destination struct. pointerOfSliceOfStruct MUST BE a pointer of slice of pointer of struct. It closes rows after mapping regardless error occurred.

Example:

var users []*Users
err := exql.MapRows(rows, &users)

func ParseTags

func ParseTags(tag string) (map[string]string, error)

func ParseType

func ParseType(t string, nullable bool) (string, error)

func Ptr

func Ptr[T any](t T) *T

Ptr returns the pointer of the argument.

func QueryForBulkInsert

func QueryForBulkInsert[T Model](modelPtrs ...T) (q.Query, error)

func QueryForInsert

func QueryForInsert(modelPtr Model) (q.Query, *reflect.Value, error)

func QueryForUpdateModel

func QueryForUpdateModel(
	updateStructPtr ModelUpdate,
	where q.Condition,
) (q.Query, error)

func Transaction

func Transaction(db *sql.DB, ctx context.Context, opts *sql.TxOptions, callback func(tx Tx) error) error

func Where

func Where(str string, args ...any) q.Condition

Types

type Column

type Column struct {
	FieldName    string         `json:"field_name"`
	FieldType    string         `json:"field_type"`
	FieldIndex   int            `json:"field_index"`
	GoFieldType  string         `json:"go_field_type"`
	Nullable     bool           `json:"nullable"`
	DefaultValue sql.NullString `json:"default_value"`
	Key          sql.NullString `json:"key"`
	Extra        sql.NullString `json:"extra"`
}

func (*Column) Field

func (c *Column) Field() string

func (*Column) IsPrimary

func (c *Column) IsPrimary() bool

func (*Column) ParseExtra

func (c *Column) ParseExtra() []string

func (*Column) UpdateField

func (c *Column) UpdateField() string

type ColumnSplitter

type ColumnSplitter func(i int) string

type DB

type DB interface {
	Saver
	Mapper
	Finder
	// DB returns *sql.DB object.
	DB() *sql.DB
	// SetDB sets *sql.DB object.
	SetDB(db *sql.DB)
	// Transaction begins a transaction and commits after the callback is called.
	// If an error is returned from the callback, it is rolled back.
	// Internally call tx.BeginTx(context.Background(), nil).
	Transaction(callback func(tx Tx) error) error
	// TransactionWithContext is same as Transaction().
	// Internally call tx.BeginTx(ctx, opts).
	TransactionWithContext(ctx context.Context, opts *sql.TxOptions, callback func(tx Tx) error) error
	// Close calls db.Close().
	Close() error
}

func NewDB

func NewDB(d *sql.DB) DB

func Open

func Open(opts *OpenOptions) (DB, error)

Open opens the connection to the database and makes exql.DB interface. If something failed, it retries automatically until given retry strategies satisfied or aborts handshaking.

Example:

db, err := exql.Open(&exql.OpenOptions{
	Url: "user:pass@tcp(127.0.0.1:3306)/database?charset=utf8mb4&parseTime=True&loc=Local",
	MaxRetryCount: 3,
	RetryInterval: 10, //sec
})

type Executor

type Executor interface {
	Exec(query string, args ...any) (sql.Result, error)
	ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
	Query(query string, args ...any) (*sql.Rows, error)
	QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
	QueryRow(query string, args ...any) *sql.Row
	QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
	Prepare(stmt string) (*sql.Stmt, error)
	PrepareContext(ctx context.Context, stmt string) (*sql.Stmt, error)
}

Executor is an abstraction of both sql.DB/sql.Tx

type Finder

type Finder interface {
	Find(q query.Query, destPtrOfStruct any) error
	FindContext(ctx context.Context, q query.Query, destPtrOfStruct any) error
	FindMany(q query.Query, destSlicePtrOfStruct any) error
	FindManyContext(ctx context.Context, q query.Query, destSlicePtrOfStruct any) error
}

Finder is an interface to execute select query and map rows into the destination.

type GenerateOptions

type GenerateOptions struct {
	OutDir  string
	Package string
	Exclude []string
}

type Generator

type Generator interface {
	Generate(opts *GenerateOptions) error
}

func NewGenerator

func NewGenerator(db *sql.DB) Generator

type Mapper deprecated

type Mapper interface {
	// Deprecated: Use Find or MapRow. It will be removed in next version.
	Map(rows *sql.Rows, destPtr any) error
	// Deprecated: Use FindContext or MapRows. It will be removed in next version.
	MapMany(rows *sql.Rows, destSlicePtr any) error
}

Deprecated: Use Finder It will be removed in next version.

type Model

type Model interface {
	TableName() string
}

type ModelMetadata

type ModelMetadata struct {
	TableName          string
	AutoIncrementField *reflect.Value
	PrimaryKeyColumns  []string
	PrimaryKeyValues   []any
	Values             q.KeyIterator[any]
}

func AggregateModelMetadata

func AggregateModelMetadata(modelPtr Model) (*ModelMetadata, error)

type ModelUpdate

type ModelUpdate interface {
	UpdateTableName() string
}

type OpenOptions

type OpenOptions struct {
	// @default "mysql"
	DriverName string
	// DSN format for database connection.
	Url string
	// @default 5
	MaxRetryCount int
	// @default 5s
	RetryInterval time.Duration
}

type Parser

type Parser interface {
	ParseTable(db *sql.DB, table string) (*Table, error)
}

func NewParser

func NewParser() Parser

type Saver

type Saver interface {
	Insert(structPtr Model) (sql.Result, error)
	InsertContext(ctx context.Context, structPtr Model) (sql.Result, error)
	Update(table string, set map[string]any, where q.Condition) (sql.Result, error)
	UpdateModel(updaterStructPtr ModelUpdate, where q.Condition) (sql.Result, error)
	UpdateContext(ctx context.Context, table string, set map[string]any, where q.Condition) (sql.Result, error)
	UpdateModelContext(ctx context.Context, updaterStructPtr ModelUpdate, where q.Condition) (sql.Result, error)
	Delete(table string, where q.Condition) (sql.Result, error)
	DeleteContext(ctx context.Context, table string, where q.Condition) (sql.Result, error)
	Exec(query q.Query) (sql.Result, error)
	ExecContext(ctx context.Context, query q.Query) (sql.Result, error)
	Query(query q.Query) (*sql.Rows, error)
	QueryContext(ctx context.Context, query q.Query) (*sql.Rows, error)
	QueryRow(query q.Query) (*sql.Row, error)
	QueryRowContext(ctx context.Context, query q.Query) (*sql.Row, error)
}

func NewSaver

func NewSaver(ex Executor) Saver

type SerialMapper

type SerialMapper interface {
	// Map reads joined rows and maps columns for each destination serially.
	// The second argument, pointerOfStruct, MUST BE a pointer of the struct.
	//
	// NOTE: DO NOT FORGET to close rows manually, as it WON'T do it automatically.
	//
	// Example:
	//
	//	var user User
	//	var favorite UserFavorite
	//	defer rows.Close()
	//	err := m.Map(rows, &user, &favorite)
	Map(rows *sql.Rows, pointersOfStruct ...any) error
}

SerialMapper is an interface for mapping a joined row into one or more destinations serially.

func NewSerialMapper

func NewSerialMapper(s ColumnSplitter) SerialMapper

type StmtExecutor

type StmtExecutor interface {
	Executor
	// Close calls all retained *sql.Stmt and clears the buffer.
	// DON'T forget to call this on the manual use.
	Close() error
}

StmtExecutor is the Executor that caches queries as *sql.Stmt. It uses the cached Stmt for the next execution if query is identical. They are held until Close() is called. This is useful for the case of executing the same query repeatedly in the for-loop. It may prevent errors caused by the db's connection pool.

Example:

stmtExecer := exql.NewStmtExecutor(tx.Tx())
defer stmtExecer.Close()
stmtSaver := exql.NewSaver(stmtExecer)

func NewStmtExecutor

func NewStmtExecutor(ex Executor) StmtExecutor

type Table

type Table struct {
	TableName string    `json:"table_name"`
	Columns   []*Column `json:"columns"`
}

func (*Table) Fields

func (t *Table) Fields() []string

func (*Table) HasJsonField

func (t *Table) HasJsonField() bool

func (*Table) HasNullField

func (t *Table) HasNullField() bool

func (*Table) HasTimeField

func (t *Table) HasTimeField() bool

type Tx

type Tx interface {
	Saver
	Finder
	Mapper
	Tx() *sql.Tx
}

Directories

Path Synopsis
mocks
mock_exql
Package mock_exql is a generated GoMock package.
Package mock_exql is a generated GoMock package.
mock_query
Package mock_query is a generated GoMock package.
Package mock_query is a generated GoMock package.
This file is generated by exql.
This file is generated by exql.
tool

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL