Help us shape the future of Go! Take the 2026 Go Developer Survey

plugins

package
v0.3.0 Latest Latest
Warning

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

Go to latest
Published: Nov 1, 2026 License: MIT Imports: 6 Imported by: 0

README

Plugin System

The plugin system provides AST-level middleware for SQL queries. Plugins transform the AST before SQL generation, enabling cross-cutting concerns like row filtering, column masking, soft-delete, and multi-tenancy without modifying query-building logic.

For library usage documentation, see the Plugins guide in the user-facing docs. This README is for developers working on or extending the plugin system itself.

Architecture

Plugins sit between the Manager (query builder) and the Visitor (SQL generator):

Manager → [Plugin 1] → [Plugin 2] → ... → Visitor → SQL string
           Transform()   Transform()         Accept()

When ToSQL() is called on any manager (SELECT, INSERT, UPDATE, DELETE), it:

  1. Creates a shallow clone of the statement AST (protecting the original)
  2. Passes the clone through each registered transformer in order
  3. Hands the transformed AST to the Visitor for SQL generation

Each transformer receives the AST node and returns a (possibly modified) node. If any transformer returns an error, SQL generation stops and the error propagates to the caller.

The Transformer Interface

The entire plugin system is built on a single interface:

// plugins/transformer.go
type Transformer interface {
    TransformSelect(core *nodes.SelectCore) (*nodes.SelectCore, error)
    TransformInsert(stmt *nodes.InsertStatement) (*nodes.InsertStatement, error)
    TransformUpdate(stmt *nodes.UpdateStatement) (*nodes.UpdateStatement, error)
    TransformDelete(stmt *nodes.DeleteStatement) (*nodes.DeleteStatement, error)
}

Embed plugins.BaseTransformer to get no-op defaults for methods you don't need. For example, a soft-delete plugin might only override TransformSelect and TransformUpdate, leaving INSERT and DELETE untouched.

The AST nodes expose all parts of their statements for inspection and modification. See nodes/select_core.go, nodes/insert_statement.go, nodes/update_statement.go, and nodes/delete_statement.go for field details.

Registration

Plugins are registered on a SelectManager via the Use() method. Multiple plugins are applied in registration order:

query := managers.NewSelectManager(table)
query.Use(softdelete.New())      // runs first
query.Use(opa.NewFromServer(...)) // runs second
Clone Protection

All managers (SelectManager, InsertManager, UpdateManager, DeleteManager) clone their statement AST before passing it to transformers. This shallow copies all slice fields (Projections, Wheres, Joins, Values, etc.) so that plugins can append to or replace slices without mutating the original. The original AST remains untouched, allowing the same query to be generated multiple times with different results (e.g. if policy inputs change between calls).

Helper: CollectTables

The plugins package provides CollectTables() to extract all table references from a SelectCore:

refs := plugins.CollectTables(core) // []TableRef
for _, ref := range refs {
    ref.Name     // underlying table name (e.g. "users")
    ref.Relation // the Node used for column references (preserves aliases)
}

This handles *nodes.Table and *nodes.TableAlias and skips subqueries. Inside a scoped transformer prefer scope.Tables(core), which also leaves out CTE names and derived-table aliases. See Writing Plugins for how the walker hands every nested query to your plugin.

Built-in Plugins

Plugin Package Status Description
Soft Delete plugins/softdelete Proof of Concept Injects IS NULL conditions to filter soft-deleted rows
OPA plugins/opa Proof of Concept Enforces Open Policy Agent policies via row filtering and column masking

Both plugins demonstrate the plugin architecture but are not production-ready. Use them as reference implementations when building your own plugins.

Writing Your Own Plugin

Step 1: Create the Package

Create a new directory under plugins/ for your plugin:

plugins/
  mytenant/
    mytenant.go
    mytenant_test.go
Step 2: Implement the Transformer Interface

Embed plugins.BaseTransformer and override the methods you need:

package mytenant

import (
    "github.com/bawdo/gosbee/nodes"
    "github.com/bawdo/gosbee/plugins"
)

type MultiTenant struct {
    plugins.BaseTransformer // provides no-op defaults
    TenantID int
}

func New(tenantID int) *MultiTenant {
    return &MultiTenant{TenantID: tenantID}
}

// TransformSelect lets a direct call (outside a manager) police every nested
// query too. It relies on TransformSelectScoped below: without it the walker
// would call TransformSelect again and recurse until the stack overflows.
func (mt *MultiTenant) TransformSelect(core *nodes.SelectCore) (*nodes.SelectCore, error) {
    return plugins.ApplySelect(mt, core)
}

// TransformSelectScoped is called by the walker once for every SelectCore in
// the query: the outer query, subqueries, CTE bodies and set-operation branches.
func (mt *MultiTenant) TransformSelectScoped(core *nodes.SelectCore, scope plugins.Scope) (*nodes.SelectCore, error) {
    for _, ref := range scope.Tables(core) {
        attr := nodes.NewAttribute(ref.Relation, "tenant_id")
        core.Wheres = append(core.Wheres, attr.Eq(mt.TenantID))
    }
    return core, nil
}

// RefusesRawSQL makes the walker refuse raw SQL, custom nodes and FROM or JOIN
// shapes the plugin cannot see, rather than letting them through unfiltered.
func (mt *MultiTenant) RefusesRawSQL() bool { return true }

// TransformUpdate, TransformInsert, TransformDelete inherited as no-ops from BaseTransformer

Key points:

  • Use scope.Tables(core) to iterate over the tables this core reads (FROM + JOINs). It leaves out CTE names and derived tables, which are not tables; the walker reaches nested queries itself
  • Use ref.Relation (not a new nodes.NewTable()) when creating column references, so a condition on users AS u is written against u and filters that row, not some other users row in scope
  • Use ref.Name when you need to match against a table name for filtering logic
  • Append conditions to core.Wheres — they are AND'd together automatically
  • Return nil, error to reject the query entirely (e.g. access denied)
  • The core you receive is already a clone — you can safely modify its slices
Step 3: Write Tests

Test directly against the SelectCore and Visitor without needing a database:

package mytenant

import (
    "testing"

    "github.com/bawdo/gosbee/nodes"
    "github.com/bawdo/gosbee/visitors"
)

func TestInjectsTenantCondition(t *testing.T) {
    users := nodes.NewTable("users")
    core := &nodes.SelectCore{From: users}

    mt := New(42)
    result, err := mt.TransformSelect(core)
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }

    got := result.Accept(visitors.NewPostgresVisitor(visitors.WithoutParams()))
    expected := `SELECT * FROM "users" WHERE "users"."tenant_id" = 42`
    if got != expected {
        t.Errorf("expected:\n  %s\ngot:\n  %s", expected, got)
    }
}

Test with joins to verify multi-table behavior:

func TestAppliedToJoinedTables(t *testing.T) {
    users := nodes.NewTable("users")
    posts := nodes.NewTable("posts")
    core := &nodes.SelectCore{
        From: users,
        Joins: []*nodes.JoinNode{
            {
                Left:  users,
                Right: posts,
                Type:  nodes.InnerJoin,
                On:    users.Col("id").Eq(posts.Col("user_id")),
            },
        },
    }

    mt := New(42)
    result, err := mt.TransformSelect(core)
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }

    got := result.Accept(visitors.NewPostgresVisitor(visitors.WithoutParams()))
    expected := `SELECT * FROM "users" INNER JOIN "posts" ON "users"."id" = "posts"."user_id" WHERE "users"."tenant_id" = 42 AND "posts"."tenant_id" = 42`
    if got != expected {
        t.Errorf("expected:\n  %s\ngot:\n  %s", expected, got)
    }
}
Step 4: Use It
query := managers.NewSelectManager(users)
query.Use(mytenant.New(42))

sql, _, err := query.ToSQL(visitors.NewPostgresVisitor(visitors.WithoutParams()))
// SELECT * FROM "users" WHERE "users"."tenant_id" = 42

Plugins compose with each other:

query.Use(softdelete.New())
query.Use(mytenant.New(42))
// SELECT * FROM "users"
//   WHERE "users"."deleted_at" IS NULL AND "users"."tenant_id" = 42
Design Guidelines
  • Modify the core directly — you receive a clone, so appending to Wheres, Projections, etc. is safe.
  • Use functional options for configuration (see softdelete.WithColumn(), opa.WithColumnResolver()).
  • Return errors for hard failures — e.g. access denied, missing configuration. The error stops SQL generation.
  • Keep transforms idempotent — ToSQL() may be called multiple times on the same manager.
  • Avoid side effects — don't make network calls in Transform() if you can fetch data beforehand and close over it (though the OPA plugin is an intentional exception since policy must be evaluated at query time).

Documentation

Overview

Package plugins defines the Transformer interface for AST middleware.

Index

Constants

This section is empty.

Variables

View Source
var ErrUnpoliceable = errors.New("plugins: query contains SQL the transformer cannot inspect")

ErrUnpoliceable is returned when a strict transformer meets SQL it cannot inspect, such as a SqlLiteral or a StringJoin. The error never includes the raw SQL text.

Functions

func ApplyNode added in v0.2.0

func ApplyNode(t Transformer, n nodes.Node) (nodes.Node, error)

ApplyNode is ApplySelect for any node, such as a SetOperationNode or a DML statement. Only SelectCores are handed to t; for anything else the walker just rewrites the children.

func ApplySelect added in v0.2.0

func ApplySelect(t Transformer, core *nodes.SelectCore) (*nodes.SelectCore, error)

ApplySelect applies t to core and to every SelectCore nested inside it (subqueries, CTE bodies, joined subqueries, set-operation branches). Inner cores are transformed before the core that contains them. The input tree is never modified: each core is copied before t sees it, and every container node on the path to a changed node is copied too.

func Attributes added in v0.2.0

func Attributes(n nodes.Node) []*nodes.Attribute

Attributes returns every Attribute in the tree under n, including those inside function calls and nested queries, in traversal order.

func ShallowAttributes added in v0.2.0

func ShallowAttributes(n nodes.Node) []*nodes.Attribute

ShallowAttributes is Attributes without descending into nested queries: a SelectCore or SetOperationNode below n is skipped. When n is itself a SelectCore its own clauses are still collected.

Types

type BaseTransformer

type BaseTransformer struct{}

BaseTransformer provides no-op defaults for all Transformer methods. Plugins embed this and override only the methods they care about.

func (BaseTransformer) TransformDelete

func (BaseTransformer) TransformInsert

func (BaseTransformer) TransformSelect

func (BaseTransformer) TransformSelect(c *nodes.SelectCore) (*nodes.SelectCore, error)

func (BaseTransformer) TransformUpdate

type Scope added in v0.2.0

type Scope struct {
	CTENames map[string]bool // CTEs declared on this core or any enclosing core; read-only

	// Qualifiers maps each name a column may be qualified with (a table name
	// or a table alias) to the underlying table name, for this core and every
	// enclosing core. Inner names shadow outer ones. Derived-table aliases and
	// CTE names are not tables, so they are absent. Read-only.
	Qualifiers map[string]string
	// contains filtered or unexported fields
}

Scope describes what is visible to the SelectCore being transformed.

CTENames and Qualifiers are read-only: the walker shares the same maps across many cores, so a transformer that wrote to them would change the scope of cores it was never shown.

func (Scope) Memo added in v0.2.0

func (s Scope) Memo(key string, fn func() (any, error)) (any, error)

Memo returns the value cached under key for this walk, calling fn to produce it the first time. Errors are not cached. On a zero Scope (one not created by a walk) fn is simply called every time.

func (Scope) Tables added in v0.2.0

func (s Scope) Tables(core *nodes.SelectCore) []TableRef

Tables returns the table relations referenced by core, as CollectTables does, minus any that name a CTE in scope and minus derived tables (an alias over a subquery). A derived table is policed by walking into it, so its alias must not be mistaken for a table name.

type ScopedTransformer added in v0.2.0

type ScopedTransformer interface {
	TransformSelectScoped(core *nodes.SelectCore, scope Scope) (*nodes.SelectCore, error)
}

ScopedTransformer is used instead of TransformSelect when implemented.

type StrictTransformer added in v0.2.0

type StrictTransformer interface {
	RefusesRawSQL() bool
}

StrictTransformer makes the walker refuse SqlLiteral nodes when RefusesRawSQL returns true.

type TableRef

type TableRef struct {
	Relation nodes.Node // *nodes.Table or *nodes.TableAlias
	Name     string     // underlying table name
}

TableRef holds a reference to a table relation and its underlying name. Relation is the node used to create column references (preserving aliases), and Name is the underlying table name (for matching/filtering).

func CollectTables

func CollectTables(core *nodes.SelectCore) []TableRef

CollectTables returns all table relations referenced in a SelectCore, including the FROM table and all JOIN targets. Subqueries and other non-table nodes are skipped.

type Transformer

type Transformer interface {
	TransformSelect(core *nodes.SelectCore) (*nodes.SelectCore, error)
	TransformInsert(stmt *nodes.InsertStatement) (*nodes.InsertStatement, error)
	TransformUpdate(stmt *nodes.UpdateStatement) (*nodes.UpdateStatement, error)
	TransformDelete(stmt *nodes.DeleteStatement) (*nodes.DeleteStatement, error)
}

Transformer is the interface that AST transformation plugins implement. Plugins embed BaseTransformer and override only the methods they need.

Directories

Path Synopsis
internal
hooks
Package hooks lets package plugins lend unexported walker features to the plugins bundled with this module, without adding them to the public API.
Package hooks lets package plugins lend unexported walker features to the plugins bundled with this module, without adding them to the public API.
Package opa provides a Transformer that enforces Open Policy Agent policies on queries by injecting policy-derived WHERE conditions.
Package opa provides a Transformer that enforces Open Policy Agent policies on queries by injecting policy-derived WHERE conditions.
Package softdelete provides a Transformer that automatically injects "column IS NULL" conditions into SELECT queries, filtering out soft-deleted rows.
Package softdelete provides a Transformer that automatically injects "column IS NULL" conditions into SELECT queries, filtering out soft-deleted rows.

Jump to

Keyboard shortcuts

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