sqlcode

package module
v0.4.0 Latest Latest
Warning

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

Go to latest
Published: Feb 22, 2023 License: MIT Imports: 13 Imported by: 0

README

sqlcode -- manage stored procedures/functions

If you have a substantial amount of SQL in your service, should you write that code as a SQL strings compiled into the binary, or as a set of stored procedures/functions? Both approaches have some big drawbacks; this tool tries to bring the benefits of both.

  1. Write stored procedures, functions and types only (permanent tables will be prevented) in *.sql-files in your code base

  2. Call sqlcode up mydb:mybranch to upload it to a temporary schema [code@mybranch] for interactive debugging on a database.

  3. When your service starts it ensures that the code is uploaded to a schema dedicated for the given version of your code ([code@ba432abf]).

Benefits over strings in backend source code:

  • Clean error messages with filename and line number
  • Fast debugging, verification and profiling of your queries against live databases before deploying your service

Benefits over stored procedures in migration files:

  • Smooth rollout of upgrades and rollbacks with multiple concurrent versions running at the same time, one for each service. With standard migrations achieving this is at least a manual process with manually versioned procedures.

  • Git diffs works properly

INSTALL

To install the CLI tool do something along these lines:

$ go build -o ~/.local/bin/sqlcode ./cli

To fetch the Go library:

$ go get github.com/vippsas/sqlcode

HOWTO

Step 1

Put your SQL code in *.sql-files in your repo. The directory you place it in is irrelevant, sqlcode will simply scan the entire subtree.

For now, sqlcode assumes a single global namespace, i.e. for each database and code repo there is a single namespace for stored procedures and functions. If you want namespacing then for now that must be done as part of function/procedure/type names.

The SQL file has a declaration header comment and should otherwise contain creation of enums, types, procedures or functions in a virtual schema [code]; always written with the brackets. Do not create tables/indexes. Like this:

-- Global constants
declare
    @EnumGasoline int = 1,
    @EnumDiesel int = 2,
    @EnumElectric int = 3;

-- Batch markers supported
go

create type [code].MyType as table (x int not null primary key);
go
-- All stored procedures/functions/types should go in the [code] schema;
-- ALWAYS INCLUDE THE BRACKETS.
create function [code].Add(@a int, @b int) returns int
as begin 
    return @a + @b + @EnumDiesel;
end;

Step 2

Verify that the sqlcode preprocessor does what you think it should do:

$ sqlcode build mypackage debug123
create type [code].MyType as table (x int not null primary key);
go
create function [code@debug123].Add(@a int, @b int) returns int
as begin
return @a + @b + 2/*=@EnumDiesel*/;
end;

go 

create function [code@debug123].Report(@t [code].MyType readonly) returns table
as return select sum(x) as Sum from @t;

go

What SQLCode does is:

  1. Replace uses of global constant declarations with their literal values
  2. Replace [code] with [code@<deployment suffix>] everywhere (but not in strings or comments)
  3. Concatenate all create procedure/function/type statements in the right order so that the SQL files can refer to names declared elsewhere without any issues.

This command is mainly useful for debugging sqlcode itself and make sure you understand how it works; sqlcode build is not normally used in your workflow.

Step 3

Install the sqlcode SQL library by running the migrations in migrations in your ordinary SQL migration pipeline. This installs some stored procedures that are used by the utilities below; in the sqlcode schema. Please read through the migration files for more information before installing.

Currently, only Microsoft SQL is supported.

You also have to do something like this for your service users and also any humans that should manually upload using sqlcode up:

alter role [sqlcode-deploy-role] add member your_service_user;

Services or users that only execute code and do not upload it can instead be added like to [sqlcode-execute-role].

NOTE: The above roles control full access to all [code@...] schemas jointly; something to keep in mind if multiple services use the same DB.

Step 4

Create a file sqlcode.yaml in the root of your repo listing the databases to access. Assuming AAD token login:

databases:
    test:
        connection: sqlserver://mytestenv.database.windows.net:1433?database=myservice
    prod:
        connection: sqlserver://myprodenv.database.windows.net:1433?database=myservice

Then upload the code manually for some quick tests:

$ sqlcode up test:mybranch

Here test refers to the database, and mybranch is a throwaway name you choose to not interfer with other users of the database, and other work you are doing yourself.

Then you can fire up DataGrip / SMSS / ... and check query plans, manually including the schema suffix:

-- debug session:
select [code@mybranch].Add(1, 2)
Step 5 (Go-specific)

Your backend service adds a call to automatically upload code during startup (currently only Go is supported). It will upload it to a schema with a name determined by hash of the SQL files involved.

First we embed the SQL source file in the compiled binary using the //go:embed feature, and call sqlcode.MustInclude to load and parse the SQL files at program startup:

package mypackage

import (
    "embed"
    "github.com/vippsas/sqlcode"
)

//go:embed *.sql
//go:embed subdir/*.sql
var SQLFS embed.FS
var SQL = sqlcode.MustInclude(SQLFS)

var EnumMyGlobal = SQL.MustIntConst("@EnumMyGlobal")

Then you have to make sure the code is uploaded when you have a DB connection pool ready; this can be during program startup, but it can also be The function will return quickly without network traffic if it has already been done for this DB by this process: Upload at the start of your program:

func main() {
	// ...
	err := SQL.EnsureUploaded(ctx, dbc)
	// ...
}

This is pretty much equivalent to

$ sqlcode up prod:dc8f9910de0d

that is, the schema suffix is chosen as service name provided, current date, and a hash of the SQL. However, SQL.EnsureUploaded will not upload a second time if it has already been done, while sqlcode up will drop the target schema and re-upload (replace).

Step 6

Once code has been uploaded, you invoke the same pre-processors on whatever SQL strings you have left inline in your code in order to support calling into [code]:

var addSql = SQL.Patch(`select [code].Add(1, 2)`)
func myfunc() {
	// ... 
	_, err := dbc.ExecContext(ctx, addSql) 
	// ...
}
Step 7

As days pass more and more SQL code is uploaded and some cleanup is needed. These features are not added to this library yet though.

Feature guide

Security model

The security conscious user should make sure to review migratins/0001.sqlcode.sql and fully understand what is going on.

Note that the sqlcode.CreateCodeSchema and sqlcode.DropCodeSchema stored procedures are signed and operate as db_owner; any user who gets granted execute on these procedures may create and drop [code@...] schemas at will. Any injection attack bugs in these procedures could provide points for privilege escalation, although no such bugs are known.

The following security measures are implemented:

  • The [code@...] schemas are owned by a special user [sqlcode-user-with-no-permissions] which are granted no rights. This disables one avenue where stored procedures gets the same permissions as the owner of the stored procedure, and one is left with the permissions of the caller of the procedure.

  • During upload of the SQL code, the user [sqlcode-deploy-sandbox-user] is impersonated to reduce privileges for the operation. This is both for security, and also so that the user does not have create table, create index permissions in the database.

Enum/global constant support

If a *.sql-file contains code like the following at the top level

declare @EnumFoo int = 3

...then this will be treated as a global constant, and inlined everywhere as 3/* =@EnumFoo */. We also support varchar(max), even correcting error messages for the shifted line numbers when literals contain newlines.

Such global constants must start with either of @Enum, @ENUM_, @enum_, @Const, @CONST_, @const_. (This is experimental; in the future perhaps we will instead use @$ or similar for SQLCode global constants).

Global constants must be declared in a batch of their own. If a source file only contains such global constants, you have to have at least one pragma in it, such as this,

--sqlcode:

declare @EnumFoo int = 3

so that the file itself will be picked up by SQLCode; SQLCode picks up files that either contains [code], or starts with --sqlcode.

The CLI command sqlcode constants will dump all the declare @EnumFoo .. statements in the subtree for easy copy+paste of everything into your debugging session.

Introspection and annotations

It can be convenient to annotate stored procedures/functions with some metadata available to a backend. For instance, consider a backend that automatically exposes endpoints based on SQL queries. To aid building such things the Go parser, when used as a library, makes introspection data available such as the name of the function, arguments etc. (features here are added as needed).

A comment immediately before a create statement is treated like a "docstring" and is available in the node representing the create statement in the DOM. There is also a convention and DOM support for an embedded YAML document in the docstring; the lines containing the YAML document should be prefixed by !-- (note the space): Example:

-- Returns JSON to return for the /myentity GET endpoint
--
--! timeoutMs: 400
--! runAs: myserviceuser
--! this:
--!  - is: ["a", "yaml", "document"]
create procedure [code].[GET:/myentity] (@entityID bigint) as begin 
    select 
        [name] = Name
    from myschema.MyEntity
    for json path
end

Documentation

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func Drop

func Drop(ctx context.Context, dbc DB, schemasuffix string) error

func Exists

func Exists(ctx context.Context, dbc DB, schemasuffix string) (bool, error)

func SchemaName

func SchemaName(suffix string) string

func SchemaSuffixFromHash

func SchemaSuffixFromHash(doc sqlparser.Document) string

Types

type Batch

type Batch struct {
	StartPos sqlparser.Pos
	Lines    string
	// contains filtered or unexported fields
}

func (Batch) LineNumberInInput

func (b Batch) LineNumberInInput(outputline int) int

LineNumberInInput transforms an error line number when executing the batch, into an absolute line number in StartPos.File

func (Batch) RelativeLineNumberInInput

func (b Batch) RelativeLineNumberInInput(outputline int) int

RelativeLineNumberInInput maps a line number from the output of preprocessing (`outputline)` to a line number in the input of the pre-processing. PS: StartPos must be considered *in addition* to this transform.

type DB

type DB interface {
	ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error)
	QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error)
	QueryRowContext(ctx context.Context, query string, args ...interface{}) *sql.Row
	Conn(ctx context.Context) (*sql.Conn, error)
	BeginTx(ctx context.Context, txOptions *sql.TxOptions) (*sql.Tx, error)
}

type Deployable

type Deployable struct {
	SchemaSuffix string
	ParsedFiles  []string // mainly for use in error messages etc
	CodeBase     sqlparser.Document
	// contains filtered or unexported fields
}

func Include

func Include(opts Options, fsys ...fs.FS) (result Deployable, err error)

Include is used to package SQL code included using the `embed` go feature; constructing a Deployable of SQL code. Currently, only a single packageName is supported, but we can make string e.g. `...string` later.

func MustInclude

func MustInclude(opts Options, fsys ...fs.FS) Deployable

func (Deployable) DropAndUpload

func (d Deployable) DropAndUpload(ctx context.Context, dbc DB) error

UploadWithOverwrite will always drop the schema if it exists, before uploading. This is suitable for named schema suffixes.

func (*Deployable) EnsureUploaded

func (d *Deployable) EnsureUploaded(ctx context.Context, dbc DB) error

EnsureUploaded checks that the schema with the suffix already exists, and if not, creates and uploads it. This is suitable for hash-based schema suffixes. A lock will be taken (globally in SQL) during the process so that multiple concurrent calls from services starting at the same time line up nicely

func (Deployable) IntConst

func (d Deployable) IntConst(s string) (int, error)

func (*Deployable) IsUploadedFromCache

func (d *Deployable) IsUploadedFromCache(dbc DB) bool

func (Deployable) MustIntConst

func (d Deployable) MustIntConst(s string) int

func (Deployable) Patch

func (d Deployable) Patch(sql string) string

Patch will preprocess the sql passed in so that it will call SQL code deployed by the receiver Deployable

func (*Deployable) Upload

func (d *Deployable) Upload(ctx context.Context, dbc DB) error

Upload will create and upload the schema; resulting in an error if the schema already exists

func (Deployable) WithSchemaSuffix

func (d Deployable) WithSchemaSuffix(schemaSuffix string) Deployable

type Options

type Options struct {
	IncludeTags []string

	// if this is set, parsing or ordering failed and it's up to the caller
	// to know what one is doing..
	PartialParseResults bool
}

Options that affect file parsing etc; pass an empty struct to get default options.

type PreprocessedFile

type PreprocessedFile struct {
	Batches []Batch
}

func Preprocess

func Preprocess(doc sqlparser.Document, schemasuffix string) (PreprocessedFile, error)

type PreprocessorError

type PreprocessorError struct {
	Pos     sqlparser.Pos
	Message string
}

func (PreprocessorError) Error

func (p PreprocessorError) Error() string

type SQLCodeParseErrors

type SQLCodeParseErrors struct {
	Errors []sqlparser.Error
}

func (SQLCodeParseErrors) Error

func (e SQLCodeParseErrors) Error() string

type SQLUserError

type SQLUserError struct {
	Wrapped mssql.Error
	Batch   Batch
}

func (SQLUserError) Error

func (s SQLUserError) Error() string

Directories

Path Synopsis
cli
cmd
example
Simple non-performant recursive descent parser for purposes of sqlcode; currently only supports the special @Enum declarations used by sqlcode.
Simple non-performant recursive descent parser for purposes of sqlcode; currently only supports the special @Enum declarations used by sqlcode.

Jump to

Keyboard shortcuts

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