mirror of
https://github.com/hanzoai/orm.git
synced 2026-10-30 15:01:22 +00:00
Tenancy is a column. Every table carries org_id first, leading its key and every index, and the org a statement acts for comes from the context through the resolver the store opens with. The same code runs on SQLite and PostgreSQL; the backend is configuration. The builder refuses a statement whose context names no org before any SQL exists, and constrains every table a statement names: WHERE for the table read, ON for each join, the same for every subquery. An insert takes its org from the context and refuses a row naming another; an update cannot move a row between orgs; an upsert's conflict target leads with org_id. Conditions are a closed set and identifiers are validated, so there is no raw SQL to reach past the constraint with. On PostgreSQL the migrator forces row-level security under a tenant policy (org_id = app.org) and a platform policy, and proves the table on every open. Each statement runs in a transaction that sets its role and app.org, both transaction-local; the login is NOINHERIT and holds nothing itself. Open refuses a superuser, BYPASSRLS, an inheriting login, or roles other than the ones Provision makes. Platform(ctx, why) is the one cross-org entry: audited, its own role, refused from any context that names an org. Tx is SERIALIZABLE on PostgreSQL and reruns on conflict. ReadOnly routes a read to a replica. One suite runs every behaviour on SQLite and on a real PostgreSQL, and holds row-level security alone with raw SQL on the store's own pool. Co-authored-by: Hanzo Dev <[email protected]>
342 lines
8.7 KiB
Go
342 lines
8.7 KiB
Go
package tenant
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"reflect"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/hanzoai/orm/query"
|
|
)
|
|
|
|
// Query reads rows into T, a struct whose fields map to columns by their `db`
|
|
// tag (or the snake_case of their name). A Query is a value: every method
|
|
// returns a new one and leaves its receiver as it was.
|
|
type Query[T any] struct {
|
|
d *DB
|
|
src source
|
|
cols []string
|
|
joins []join
|
|
where []Cond
|
|
group []string
|
|
order []string
|
|
limit int
|
|
offset int
|
|
err error
|
|
}
|
|
|
|
type join struct {
|
|
left bool
|
|
src source
|
|
on Cond
|
|
}
|
|
|
|
// Select starts a read of table — "table", "table alias" or "table AS alias" —
|
|
// into T. cols is the projection: columns ("id", "m.role"), aggregates
|
|
// ("COUNT(*)", "SUM(amount)", "MAX(m.at)"), each optionally "AS name"; with
|
|
// none, the columns T's fields map to.
|
|
func (d *DB) Select[T any](table string, cols ...string) *Query[T] {
|
|
q := &Query[T]{d: d, cols: cols, limit: -1}
|
|
q.src, q.err = d.st.parseSource(table)
|
|
if q.err == nil && len(cols) == 0 {
|
|
q.cols, q.err = columnsOf(reflect.TypeFor[T]())
|
|
}
|
|
return q
|
|
}
|
|
|
|
func (q *Query[T]) with(f func(*Query[T])) *Query[T] {
|
|
c := *q
|
|
c.cols = slices.Clip(c.cols)
|
|
c.joins = slices.Clip(c.joins)
|
|
c.where = slices.Clip(c.where)
|
|
c.group = slices.Clip(c.group)
|
|
c.order = slices.Clip(c.order)
|
|
if c.err == nil {
|
|
f(&c)
|
|
}
|
|
return &c
|
|
}
|
|
|
|
// Where adds a condition. Conditions conjoin: a later Where narrows the read
|
|
// and can never widen it. A nil c adds nothing.
|
|
func (q *Query[T]) Where(c Cond) *Query[T] {
|
|
return q.with(func(q *Query[T]) {
|
|
if c != nil {
|
|
q.where = append(q.where, c)
|
|
}
|
|
})
|
|
}
|
|
|
|
// Join adds an inner join of table ("table alias") on the condition on.
|
|
func (q *Query[T]) Join(table string, on Cond) *Query[T] {
|
|
return q.join(false, table, on)
|
|
}
|
|
|
|
// LeftJoin adds a left join. The tenant constraint on the joined table goes
|
|
// in its ON, so the join yields the org's own rows or nulls, never another
|
|
// org's.
|
|
func (q *Query[T]) LeftJoin(table string, on Cond) *Query[T] {
|
|
return q.join(true, table, on)
|
|
}
|
|
|
|
func (q *Query[T]) join(left bool, table string, on Cond) *Query[T] {
|
|
return q.with(func(q *Query[T]) {
|
|
src, err := q.d.st.parseSource(table)
|
|
switch {
|
|
case err != nil:
|
|
q.err = err
|
|
case on == nil:
|
|
q.err = fmt.Errorf("tenant: join %s with no condition", table)
|
|
default:
|
|
q.joins = append(q.joins, join{left, src, on})
|
|
}
|
|
})
|
|
}
|
|
|
|
// OrderBy orders by each "column" or "column DESC" in turn.
|
|
func (q *Query[T]) OrderBy(cols ...string) *Query[T] {
|
|
return q.with(func(q *Query[T]) { q.order = append(q.order, cols...) })
|
|
}
|
|
|
|
// GroupBy groups by the given columns.
|
|
func (q *Query[T]) GroupBy(cols ...string) *Query[T] {
|
|
return q.with(func(q *Query[T]) { q.group = append(q.group, cols...) })
|
|
}
|
|
|
|
// Limit reads at most n rows.
|
|
func (q *Query[T]) Limit(n int) *Query[T] {
|
|
return q.with(func(q *Query[T]) {
|
|
if n < 0 {
|
|
q.err = fmt.Errorf("tenant: limit %d", n)
|
|
}
|
|
q.limit = n
|
|
})
|
|
}
|
|
|
|
// Offset skips the first n rows.
|
|
func (q *Query[T]) Offset(n int) *Query[T] {
|
|
return q.with(func(q *Query[T]) {
|
|
if n < 0 {
|
|
q.err = fmt.Errorf("tenant: offset %d", n)
|
|
}
|
|
q.offset = n
|
|
})
|
|
}
|
|
|
|
// All reads every row.
|
|
func (q *Query[T]) All(ctx context.Context) ([]T, error) {
|
|
var out []T
|
|
err := q.read(ctx, false, func(x *query.Tx, text string, params query.Params) error {
|
|
return x.NewQuery(text).Bind(params).WithContext(ctx).All(&out)
|
|
})
|
|
return out, err
|
|
}
|
|
|
|
// One reads the first row, or reports sql.ErrNoRows when there is none.
|
|
func (q *Query[T]) One(ctx context.Context) (T, error) {
|
|
var out T
|
|
one := q
|
|
if q.limit < 0 {
|
|
one = q.Limit(1)
|
|
}
|
|
err := one.read(ctx, false, func(x *query.Tx, text string, params query.Params) error {
|
|
return x.NewQuery(text).Bind(params).WithContext(ctx).One(&out)
|
|
})
|
|
return out, err
|
|
}
|
|
|
|
// First reads the first row and whether there was one.
|
|
func (q *Query[T]) First(ctx context.Context) (T, bool, error) {
|
|
out, err := q.One(ctx)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return out, false, nil
|
|
}
|
|
return out, err == nil, err
|
|
}
|
|
|
|
// Count counts the rows the query reads.
|
|
func (q *Query[T]) Count(ctx context.Context) (int64, error) {
|
|
var n int64
|
|
err := q.read(ctx, true, func(x *query.Tx, text string, params query.Params) error {
|
|
return x.NewQuery(text).Bind(params).WithContext(ctx).Row(&n)
|
|
})
|
|
return n, err
|
|
}
|
|
|
|
// Exists reports whether the query reads any row.
|
|
func (q *Query[T]) Exists(ctx context.Context) (bool, error) {
|
|
n, err := q.Limit(1).Count(ctx)
|
|
return n > 0, err
|
|
}
|
|
|
|
func (q *Query[T]) read(ctx context.Context, count bool, fn func(x *query.Tx, text string, params query.Params) error) error {
|
|
if q.err != nil {
|
|
return q.err
|
|
}
|
|
return q.d.run(ctx, true, func(x *query.Tx, org string) error {
|
|
b := newBuild(q.d.st, org)
|
|
text, err := q.render(b, count)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return fn(x, text, b.params)
|
|
})
|
|
}
|
|
|
|
// render writes the statement. count wraps it to count its rows.
|
|
func (q *Query[T]) render(b *build, count bool) (string, error) {
|
|
st := b.st
|
|
var s strings.Builder
|
|
s.WriteString("SELECT ")
|
|
if count {
|
|
s.WriteString("1")
|
|
} else {
|
|
for i, c := range q.cols {
|
|
item, err := st.item(c)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if i > 0 {
|
|
s.WriteString(", ")
|
|
}
|
|
s.WriteString(item)
|
|
}
|
|
}
|
|
s.WriteString(" FROM ")
|
|
s.WriteString(st.from(q.src))
|
|
for _, j := range q.joins {
|
|
on, err := j.on.sql(b)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if j.left {
|
|
s.WriteString(" LEFT JOIN ")
|
|
} else {
|
|
s.WriteString(" JOIN ")
|
|
}
|
|
s.WriteString(st.from(j.src))
|
|
s.WriteString(" ON ")
|
|
s.WriteString(and(on, b.scoped(j.src.alias)))
|
|
}
|
|
parts := make([]string, 0, len(q.where)+1)
|
|
for _, c := range q.where {
|
|
w, err := c.sql(b)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
parts = append(parts, w)
|
|
}
|
|
parts = append(parts, b.scoped(q.src.alias))
|
|
if w := and(parts...); w != "" {
|
|
s.WriteString(" WHERE ")
|
|
s.WriteString(w)
|
|
}
|
|
if len(q.group) > 0 {
|
|
s.WriteString(" GROUP BY ")
|
|
for i, g := range q.group {
|
|
if err := ref(g); err != nil {
|
|
return "", fmt.Errorf("tenant: group by: %w", err)
|
|
}
|
|
if i > 0 {
|
|
s.WriteString(", ")
|
|
}
|
|
s.WriteString(st.col(g))
|
|
}
|
|
}
|
|
if !count && len(q.order) > 0 {
|
|
s.WriteString(" ORDER BY ")
|
|
for i, o := range q.order {
|
|
item, err := st.orderItem(o)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if i > 0 {
|
|
s.WriteString(", ")
|
|
}
|
|
s.WriteString(item)
|
|
}
|
|
}
|
|
if q.limit >= 0 {
|
|
s.WriteString(" LIMIT " + strconv.Itoa(q.limit))
|
|
}
|
|
if q.offset > 0 {
|
|
if q.limit < 0 && !st.pg {
|
|
s.WriteString(" LIMIT -1") // SQLite has no OFFSET without a LIMIT
|
|
}
|
|
s.WriteString(" OFFSET " + strconv.Itoa(q.offset))
|
|
}
|
|
if count {
|
|
return "SELECT COUNT(*) FROM (" + s.String() + ") AS n", nil
|
|
}
|
|
return s.String(), nil
|
|
}
|
|
|
|
var aggregates = map[string]bool{"COUNT": true, "SUM": true, "MIN": true, "MAX": true, "AVG": true}
|
|
|
|
// item renders one projection item: a column or an aggregate of one, with an
|
|
// optional "AS name". That is the whole grammar, so no projection is SQL a
|
|
// caller wrote.
|
|
func (st *store) item(s string) (string, error) {
|
|
expr, alias := strings.TrimSpace(s), ""
|
|
if i := strings.LastIndex(strings.ToUpper(expr), " AS "); i >= 0 {
|
|
expr, alias = strings.TrimSpace(expr[:i]), strings.TrimSpace(expr[i+4:])
|
|
if err := ident(alias); err != nil {
|
|
return "", fmt.Errorf("tenant: column %q: %w", s, err)
|
|
}
|
|
}
|
|
out := ""
|
|
if open := strings.IndexByte(expr, '('); open >= 0 {
|
|
fn := strings.ToUpper(strings.TrimSpace(expr[:open]))
|
|
if !aggregates[fn] || !strings.HasSuffix(expr, ")") {
|
|
return "", fmt.Errorf("tenant: column %q: only COUNT, SUM, MIN, MAX and AVG of a column", s)
|
|
}
|
|
arg := strings.TrimSpace(expr[open+1 : len(expr)-1])
|
|
distinct := ""
|
|
if f := strings.Fields(arg); len(f) == 2 && strings.EqualFold(f[0], "distinct") {
|
|
distinct, arg = "DISTINCT ", f[1]
|
|
}
|
|
switch {
|
|
case arg == "*" && fn == "COUNT" && distinct == "":
|
|
out = "COUNT(*)"
|
|
case ref(arg) == nil:
|
|
out = fn + "(" + distinct + st.col(arg) + ")"
|
|
default:
|
|
return "", fmt.Errorf("tenant: column %q: an aggregate takes one column", s)
|
|
}
|
|
} else {
|
|
if err := ref(expr); err != nil {
|
|
return "", fmt.Errorf("tenant: column %q: %w", s, err)
|
|
}
|
|
out = st.col(expr)
|
|
}
|
|
if alias != "" {
|
|
out += " AS " + st.quote(alias)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// orderItem renders "column" or "column ASC|DESC".
|
|
func (st *store) orderItem(s string) (string, error) {
|
|
f := strings.Fields(s)
|
|
if len(f) == 0 || len(f) > 2 {
|
|
return "", fmt.Errorf("tenant: order by %q", s)
|
|
}
|
|
if err := ref(f[0]); err != nil {
|
|
return "", fmt.Errorf("tenant: order by: %w", err)
|
|
}
|
|
out := st.col(f[0])
|
|
if len(f) == 2 {
|
|
switch strings.ToUpper(f[1]) {
|
|
case "ASC", "DESC":
|
|
out += " " + strings.ToUpper(f[1])
|
|
default:
|
|
return "", fmt.Errorf("tenant: order by %q: ASC or DESC", s)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|