diff --git a/README.md b/README.md index 642087b..c269390 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,18 @@ # sqlinsert -Generate SQL INSERT statement with bind parameters directly from a Go struct. +Generate a SQL INSERT statement with bind parameters directly from a Go struct. [![Go Reference](https://pkg.go.dev/badge/github.com/zachvictor/sqlinsert.svg)](https://pkg.go.dev/github.com/zachvictor/sqlinsert) ## Features * Define column names in struct tags. * Struct values become bind arguments. -* Use SQL outputs and Args slice piecemeal. Or, use `Insert()`/`InsertContext()` with a `sql.Conn`, `sql.DB`, or `sql.Tx` to execute the INSERT statement directly, all in one blow. +* Use SQL outputs and Args slice piecemeal. Or, use `Insert()`/`InsertContext()` with a `sql.Conn`, `sql.DB`, or +`sql.Tx` to execute the INSERT statement directly. * Works seamlessly with Go standard library [database/sql](https://pkg.go.dev/database/sql) package. -* Supports bind parameter token types of MySQL, PostgreSQL, Oracle, SingleStore (MemSQL), SQL Server (T-SQL), and their equivalents. +* Supports bind parameter token types of MySQL, PostgreSQL, Oracle, SingleStore (MemSQL), SQL Server (T-SQL), and their +equivalents. * Supports customized struct tags and token types. -* Supports Go 1.15 and later. -* 100% unit test coverage. +* Supports Go 1.8 to 1.19. +* Test coverage: 100% files, 97.5% statements. Tested on Go 1.15, 1.17, and 1.18. ## Example ### Given @@ -27,17 +29,17 @@ CREATE TABLE candy ( ``` ```go -type candyInsert struct { - Id string `col:"id"` - Name string `col:"candy_name"` - FormFactor string `col:"form_factor"` - Description string `col:"description"` - Mfr string `col:"manufacturer"` - Weight float64 `col:"weight_grams"` - Timestamp time.Time `col:"ts"` +type CandyInsert struct { + Id string `col:"id"` + Name string `col:"candy_name"` + FormFactor string `col:"form_factor"` + Description string `col:"description"` + Mfr string `col:"manufacturer"` + Weight float64 `col:"weight_grams"` + Timestamp time.Time `col:"ts"` } -var rec = candyInsert{ +var rec = CandyInsert{ Id: `c0600afd-78a7-4a1a-87c5-1bc48cafd14e`, Name: `Gougat`, FormFactor: `Package`, @@ -50,16 +52,71 @@ var rec = candyInsert{ ### Before ```go -stmt, _ := db.Prepare(`INSERT INTO candy - (id, candy_name, form_factor, description, manufacturer, weight_grams, ts) - VALUES ($1, $2, $3, $4, $5, $6, $7)`) -_, err := stmt.Exec(candyInsert.Id, candyInsert.Name, candyInsert.FormFactor, +stmt, _ := db.Prepare(`INSERT INTO candy + (id, candy_name, form_factor, description, manufacturer, weight_grams, ts) + VALUES (?, ?, ?, ?, ?, ?, ?)`) +_, err := stmt.Exec(candyInsert.Id, candyInsert.Name, candyInsert.FormFactor, candyInsert.Description, candyInsert.Mfr, candyInsert.Weight, candyInsert.Timestamp) ``` ### After ```go -sqlinsert.UseTokenType = OrdinalNumberTokenType -ins := sqlinsert.NewInsert(`candy`, rec) +ins := sqlinsert.Insert{`candy`, &rec} _, err := ins.Insert(db) -``` \ No newline at end of file +``` + +## This is not an ORM + +### Hide nothing +Unlike ORMs, `sqlinsert` does **not** create an abstraction layer over SQL relations, nor does it restructure SQL +functions. +The aim is to keep it simple and hide nothing. +`sqlinsert` is fundamentally a helper for [database/sql](https://pkg.go.dev/database/sql). +It simply maps struct fields to INSERT elements: +* struct tags +=> SQL columns and tokens `string` +=> [Prepare](https://pkg.go.dev/database/sql@go1.17#DB.Prepare) `query string` +* struct values +=> bind args `[]interface{}` +=> [Exec](https://pkg.go.dev/database/sql@go1.17#Stmt.Exec) `args ...interface{}` +([Go 1.18](https://pkg.go.dev/database/sql@go1.18#DB.ExecContext)+ `args ...any`) + +### Use only what you need +All aspects of SQL INSERT remain in your control: +* *I just want the column names for my SQL.* `Insert.Columns()` +* *I just want the parameter-tokens for my SQL.* `Insert.Params()` +* *I just want the bind args for my Exec() call.* `Insert.Args()` +* *I just want a Prepare-Exec wrapper.* `Insert.Insert()` + +## This is a helper + +### Let SQL be great +SQL’s INSERT is already as close to functionally pure as possible. Why would we change that? Its simplicity and +directness are its power. + +### Let database/sql be great +Some database vendors support collection types for bind parameters, some don’t. +Some database drivers support slices for bind args, some don’t. +The complexity of this reality is met admirably by [database/sql](https://pkg.go.dev/database/sql) +with the _necessary_ amount of flexibility and abstraction: +*flexibility* in open-ended SQL; +*abstraction* in the variadic `args ...interface{}` for bind args. +In this way, [database/sql](https://pkg.go.dev/database/sql) respects INSERT’s power, +hiding nothing even as it tolerates the vagaries of bind-parameter handling among database vendors and drivers. + +### Let Go be great +Go structs support ordered fields, strong types, and field metadata via [tags](https://go.dev/ref/spec#Tag) and +[reflection](https://pkg.go.dev/reflect#StructTag). +In these respects, the Go struct can encapsulate the information of a SQL INSERT-row perfectly and completely. +`sqlinsert` uses these features of Go structs to makes your SQL INSERT experience more Go-idiomatic. + +## Limitations of the Prepare-Exec wrappers +`Insert.Insert` and `Insert.Context` are for simple binding *only.* +In the spirit of "hide nothing," these do *not* support SQL operations in the `VALUES` clause. +If you require, say— +```sql +INSERT INTO foo (bar, baz, oof) VALUES (some_function(?), REPLACE(?, 'oink', 'moo'), ? + ?); +``` +—then you can use `sqlinsert.Insert` methods piecemeal. +For example, use `Insert.Columns` to build the column list for `Prepare` +and `Insert.Args` to marshal the args for `Exec`/`ExecContext`. diff --git a/pkg/insert.go b/pkg/insert.go new file mode 100644 index 0000000..40fbb50 --- /dev/null +++ b/pkg/insert.go @@ -0,0 +1,165 @@ +package sqlinsert + +import ( + "context" + "database/sql" + "fmt" + "reflect" + "strings" +) + +// InsertWith models functionality needed to execute a SQL INSERT statement with database/sql via sql.DB or sql.Tx. +// Note: sql.Conn is also supported, however, for PrepareContext and ExecContext only. +type InsertWith interface { + Prepare(query string) (*sql.Stmt, error) + PrepareContext(ctx context.Context, query string) (*sql.Stmt, error) + Exec(query string, args ...interface{}) (sql.Result, error) + ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error) +} + +// Inserter models functionality to produce a valid SQL INSERT statement with bind args. +type Inserter interface { + Tokenize(tokenType TokenType) string + Columns() string + Params() string + SQL() string + Args() []interface{} + Insert(with InsertWith) (*sql.Stmt, error) + InsertContext(ctx context.Context, with InsertWith) (*sql.Stmt, error) +} + +// Insert models data used to produce a valid SQL INSERT statement with bind args. +// Table is the table name. Data is either a struct with column-name tagged fields and the data to be inserted or +// a slice struct (struct ptr works too). Private recordType and recordValue fields are used with reflection to get +// struct tags for Insert.Columns, Insert.Params, and Insert.SQL and to retrieve values for Insert.Args. +type Insert struct { + Table string + Data interface{} +} + +// Columns returns the comma-separated list of column names-as-tokens for the SQL INSERT statement. +// Multi Row Insert: Insert.Data is a slice; first item in slice is +func (ins *Insert) Columns() string { + v := reflect.ValueOf(ins.Data) + if v.Kind() == reflect.Slice { + if v.Index(0).Kind() == reflect.Pointer { + return Tokenize(v.Index(0).Elem().Type(), ColumnNameTokenType) + } else { + return Tokenize(v.Index(0).Type(), ColumnNameTokenType) + } + } else if v.Kind() == reflect.Pointer { + return Tokenize(v.Elem().Type(), ColumnNameTokenType) + } else { + return Tokenize(v.Type(), ColumnNameTokenType) + } +} + +// Params returns the comma-separated list of bind param tokens for the SQL INSERT statement. +func (ins *Insert) Params() string { + v := reflect.ValueOf(ins.Data) + if v.Kind() == reflect.Slice { + var ( + b strings.Builder + paramRow string + ) + if v.Index(0).Kind() == reflect.Pointer { + paramRow = Tokenize(v.Index(0).Elem().Type(), UseTokenType) + } else { + paramRow = Tokenize(v.Index(0).Type(), UseTokenType) + } + b.WriteString(paramRow) + for i := 1; i < v.Len(); i++ { + b.WriteString(`,`) + b.WriteString(paramRow) + } + return b.String() + } else if v.Kind() == reflect.Pointer { + return Tokenize(v.Elem().Type(), UseTokenType) + } else { + return Tokenize(v.Type(), UseTokenType) + } +} + +// SQL returns the full parameterized SQL INSERT statement. +func (ins *Insert) SQL() string { + var insertSQL strings.Builder + _, _ = fmt.Fprintf(&insertSQL, `INSERT INTO %s %s VALUES %s`, + ins.Table, ins.Columns(), ins.Params()) + return insertSQL.String() +} + +// Args returns the arguments to be bound in Insert() or the variadic Exec/ExecContext functions in database/sql. +func (ins *Insert) Args() []interface{} { + var ( + data reflect.Value + rec reflect.Value + recType reflect.Type + args []interface{} + ) + data = reflect.ValueOf(ins.Data) + if data.Kind() == reflect.Slice { // Multi row INSERT: Insert.Data is a slice-of-struct-pointer or slice-of-struct + argIndex := -1 + if data.Index(0).Kind() == reflect.Pointer { // First slice element is struct pointers + recType = data.Index(0).Elem().Type() + } else { // First slice element is struct + recType = data.Index(0).Type() + } + numRecs := data.Len() + numFieldsPerRec := recType.NumField() + numBindArgs := numRecs * numFieldsPerRec + args = make([]interface{}, numBindArgs) + for rowIndex := 0; rowIndex < data.Len(); rowIndex++ { + if data.Index(0).Kind() == reflect.Pointer { + rec = data.Index(rowIndex).Elem() // Cur slice elem is struct pointer, get arg val from ref-element + } else { + rec = data.Index(rowIndex) // Cur slice elem is struct, can get arg val directly + } + for fieldIndex := 0; fieldIndex < numFieldsPerRec; fieldIndex++ { + argIndex += 1 + args[argIndex] = rec.Field(fieldIndex).Interface() + } + } + return args + } else { // Single-row INSERT: Insert.Data must be a struct pointer or struct (otherwise reflect will panic) + if data.Kind() == reflect.Pointer { // Row information via struct pointer + recType = data.Elem().Type() + rec = data.Elem() + } else { // Row information via struct + recType = data.Type() + rec = data + } + args = make([]interface{}, recType.NumField()) + for i := 0; i < recType.NumField(); i++ { + args[i] = rec.Field(i).Interface() + } + return args + } +} + +// Insert prepares and executes a SQL INSERT statement on a *sql.DB, *sql.Tx, +// or other Inserter-compatible interface to Prepare and Exec. +func (ins *Insert) Insert(with InsertWith) (*sql.Stmt, error) { + stmt, err := with.Prepare(ins.SQL()) + if err != nil { + return nil, err + } + defer func(stmt *sql.Stmt) { + _ = stmt.Close() + }(stmt) + _, err = stmt.Exec(ins.Args()...) + return stmt, err +} + +// InsertContext prepares and executes a SQL INSERT statement on a *sql.DB, *sql.Tx, *sql.Conn, +// or other Inserter-compatible interface to PrepareContext and ExecContext. +func (ins *Insert) InsertContext(ctx context.Context, with InsertWith) (*sql.Stmt, error) { + stmt, err := with.Prepare(ins.SQL()) + if err != nil { + return nil, err + } + defer func(stmt *sql.Stmt) { + _ = stmt.Close() + }(stmt) + _, err = stmt.ExecContext(ctx, ins.Args()...) + return stmt, err +} diff --git a/pkg/insert_test.go b/pkg/insert_test.go new file mode 100644 index 0000000..ad6ad61 --- /dev/null +++ b/pkg/insert_test.go @@ -0,0 +1,372 @@ +package sqlinsert + +import ( + "context" + "github.com/DATA-DOG/go-sqlmock" + "reflect" + "regexp" + "testing" + "time" +) + +/* Insert.Columns */ + +// - Single-row Insert.Columns + +func TestColumnsOneRecValue(t *testing.T) { + ins := Insert{tbl, recValue} + expected := `(id,candy_name,form_factor,description,manufacturer,weight_grams,ts)` + columns := ins.Columns() + if expected != columns { + t.Fatalf(`expected "%s", got "%s"`, expected, columns) + } +} + +func TestColumnsOneRecPointer(t *testing.T) { + ins := Insert{tbl, recPointer} + expected := `(id,candy_name,form_factor,description,manufacturer,weight_grams,ts)` + columns := ins.Columns() + if expected != columns { + t.Fatalf(`expected "%s", got "%s"`, expected, columns) + } +} + +// - Multi-row Insert.Columns + +func TestColumnsManyRecsValues(t *testing.T) { + ins := Insert{tbl, fiveRecsValues} + expected := `(id,candy_name,form_factor,description,manufacturer,weight_grams,ts)` + columns := ins.Columns() + if expected != columns { + t.Fatalf(`expected "%s", got "%s"`, expected, columns) + } +} + +func TestColumnsManyRecsPointers(t *testing.T) { + ins := Insert{tbl, fiveRecsPointers} + expected := `(id,candy_name,form_factor,description,manufacturer,weight_grams,ts)` + columns := ins.Columns() + if expected != columns { + t.Fatalf(`expected "%s", got "%s"`, expected, columns) + } +} + +/* Insert.Params */ + +// - Single-row Insert.Params + +func TestParamsOneRecValue(t *testing.T) { + UseTokenType = QuestionMarkTokenType + ins := Insert{tbl, recValue} + expected := `(?,?,?,?,?,?,?)` + params := ins.Params() + if expected != params { + t.Fatalf(`expected "%s", got "%s"`, expected, params) + } +} + +func TestParamsOneRecPointer(t *testing.T) { + UseTokenType = QuestionMarkTokenType + ins := Insert{tbl, recPointer} + expected := `(?,?,?,?,?,?,?)` + params := ins.Params() + if expected != params { + t.Fatalf(`expected "%s", got "%s"`, expected, params) + } +} + +// - Multi-row Insert.Params + +func TestParamsManyRecsValues(t *testing.T) { + UseTokenType = QuestionMarkTokenType + ins := Insert{tbl, fiveRecsValues} + expected := `(?,?,?,?,?,?,?),(?,?,?,?,?,?,?),(?,?,?,?,?,?,?),(?,?,?,?,?,?,?),(?,?,?,?,?,?,?)` + params := ins.Params() + if expected != params { + t.Fatalf(`expected "%s", got "%s"`, expected, params) + } +} + +func TestParamsManyRecsPointers(t *testing.T) { + UseTokenType = QuestionMarkTokenType + ins := Insert{tbl, fiveRecsPointers} + expected := `(?,?,?,?,?,?,?),(?,?,?,?,?,?,?),(?,?,?,?,?,?,?),(?,?,?,?,?,?,?),(?,?,?,?,?,?,?)` + params := ins.Params() + if expected != params { + t.Fatalf(`expected "%s", got "%s"`, expected, params) + } +} + +/* Insert.SQL */ + +// - Single-row Insert.SQL + +func TestSQLOneRecValue(t *testing.T) { + UseTokenType = OrdinalNumberTokenType + ins := Insert{tbl, recValue} + expected := `INSERT INTO candy (id,candy_name,form_factor,description,manufacturer,weight_grams,ts) VALUES ($1,$2,$3,$4,$5,$6,$7)` + insertSQL := ins.SQL() + if expected != insertSQL { + t.Fatalf(`expected "%s", got "%s"`, expected, insertSQL) + } +} + +func TestSQLOneRecPointer(t *testing.T) { + UseTokenType = OrdinalNumberTokenType + ins := Insert{tbl, recPointer} + expected := `INSERT INTO candy (id,candy_name,form_factor,description,manufacturer,weight_grams,ts) VALUES ($1,$2,$3,$4,$5,$6,$7)` + insertSQL := ins.SQL() + if expected != insertSQL { + t.Fatalf(`expected "%s", got "%s"`, expected, insertSQL) + } +} + +// - Multi-row Insert.SQL + +func TestSQLManyRecsValues(t *testing.T) { + UseTokenType = OrdinalNumberTokenType + ins := Insert{tbl, fiveRecsValues} + expected := `INSERT INTO candy (id,candy_name,form_factor,description,manufacturer,weight_grams,ts) VALUES ($1,$2,$3,$4,$5,$6,$7),($1,$2,$3,$4,$5,$6,$7),($1,$2,$3,$4,$5,$6,$7),($1,$2,$3,$4,$5,$6,$7),($1,$2,$3,$4,$5,$6,$7)` + insertSQL := ins.SQL() + if expected != insertSQL { + t.Fatalf(`expected "%s", got "%s"`, expected, insertSQL) + } +} + +func TestSQLManyRecsPointers(t *testing.T) { + UseTokenType = OrdinalNumberTokenType + ins := Insert{tbl, fiveRecsPointers} + expected := `INSERT INTO candy (id,candy_name,form_factor,description,manufacturer,weight_grams,ts) VALUES ($1,$2,$3,$4,$5,$6,$7),($1,$2,$3,$4,$5,$6,$7),($1,$2,$3,$4,$5,$6,$7),($1,$2,$3,$4,$5,$6,$7),($1,$2,$3,$4,$5,$6,$7)` + insertSQL := ins.SQL() + if expected != insertSQL { + t.Fatalf(`expected "%s", got "%s"`, expected, insertSQL) + } +} + +/* Insert.Args */ + +// - Single-row Insert.Args + +func TestArgsOneRecValue(t *testing.T) { + ins := Insert{tbl, recValue} + expected := []interface{}{ + `c0600afd-78a7-4a1a-87c5-1bc48cafd14e`, + `Gougat`, + `Package`, + `tastes like gopher feed`, + `Gouggle`, + 1.1618, + time.Time{}, + } + args := ins.Args() + if !reflect.DeepEqual(expected, args) { + t.Fatalf(`expected "%s", got "%s"`, expected, args) + } +} + +func TestArgsOneRecPointer(t *testing.T) { + ins := Insert{tbl, recPointer} + expected := []interface{}{ + `c0600afd-78a7-4a1a-87c5-1bc48cafd14e`, + `Gougat`, + `Package`, + `tastes like gopher feed`, + `Gouggle`, + 1.1618, + time.Time{}, + } + args := ins.Args() + if !reflect.DeepEqual(expected, args) { + t.Fatalf(`expected "%s", got "%s"`, expected, args) + } +} + +// - Multi-row Insert.Args + +func TestArgsManyRecsValues(t *testing.T) { + ins := Insert{tbl, fiveRecsValues} + expected := []interface{}{ + `a`, `a`, `a`, `a`, `a`, 1.1, time.Time{}, + `b`, `b`, `b`, `b`, `b`, 2.1, time.Time{}, + `c`, `c`, `c`, `c`, `c`, 3.1, time.Time{}, + `d`, `d`, `d`, `d`, `d`, 4.1, time.Time{}, + `e`, `e`, `e`, `e`, `e`, 5.1, time.Time{}, + } + args := ins.Args() + if !reflect.DeepEqual(expected, args) { + t.Fatalf(`expected "%s", got "%s"`, expected, args) + } +} + +func TestArgsManyRecsPointers(t *testing.T) { + ins := Insert{tbl, fiveRecsPointers} + expected := []interface{}{ + `a`, `a`, `a`, `a`, `a`, 1.1, time.Time{}, + `b`, `b`, `b`, `b`, `b`, 2.1, time.Time{}, + `c`, `c`, `c`, `c`, `c`, 3.1, time.Time{}, + `d`, `d`, `d`, `d`, `d`, 4.1, time.Time{}, + `e`, `e`, `e`, `e`, `e`, 5.1, time.Time{}, + } + args := ins.Args() + if !reflect.DeepEqual(expected, args) { + t.Fatalf(`expected "%s", got "%s"`, expected, args) + } +} + +/* INSERT */ + +// - Single-row Insert.Insert, Insert.InsertContext + +// TestInsertOneRecValue tests single-row insert with every token type using struct input +func TestInsertOneRecValue(t *testing.T) { + for tt := range valuesTokenTypes { + UseTokenType = TokenType(tt) + ins := Insert{tbl, recValue} + s := regexp.QuoteMeta(ins.SQL()) + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf(`failed to construct SQL mock %s`, err) + } + mock.ExpectPrepare(s) + mock.ExpectExec(s).WillReturnResult(sqlmock.NewResult(1, 1)) + _, err = ins.Insert(db) + if err != nil { + t.Fatalf(`failed at Insert, could not execute SQL statement %s`, err) + } + } +} + +// TestInsertOneRecPointer tests single-row insert with every token type using struct-pointer input +func TestInsertOneRecPointer(t *testing.T) { + for tt := range valuesTokenTypes { + UseTokenType = TokenType(tt) + ins := Insert{tbl, recPointer} + s := regexp.QuoteMeta(ins.SQL()) + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf(`failed to construct SQL mock %s`, err) + } + mock.ExpectPrepare(s) + mock.ExpectExec(s).WillReturnResult(sqlmock.NewResult(1, 1)) + _, err = ins.Insert(db) + if err != nil { + t.Fatalf(`failed at Insert, could not execute SQL statement %s`, err) + } + } +} + +// TestInsertContextOneRecValue tests single-row insert with context with every token type using struct input +func TestInsertContextOneRecValue(t *testing.T) { + for tt := range valuesTokenTypes { + UseTokenType = TokenType(tt) + ins := Insert{tbl, recValue} + s := regexp.QuoteMeta(ins.SQL()) + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf(`failed to construct SQL mock %s`, err) + } + mock.ExpectPrepare(s) + mock.ExpectExec(s).WillReturnResult(sqlmock.NewResult(1, 1)) + _, err = ins.InsertContext(context.Background(), db) + if err != nil { + t.Fatalf(`failed at InsertContext, could not execute SQL statement %s`, err) + } + } +} + +// TestInsertContextOneRecPointer tests single-row insert with context with every token type using struct-pointer input +func TestInsertContextOneRecPointer(t *testing.T) { + for tt := range valuesTokenTypes { + UseTokenType = TokenType(tt) + ins := Insert{tbl, recPointer} + s := regexp.QuoteMeta(ins.SQL()) + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf(`failed to construct SQL mock %s`, err) + } + mock.ExpectPrepare(s) + mock.ExpectExec(s).WillReturnResult(sqlmock.NewResult(1, 1)) + _, err = ins.InsertContext(context.Background(), db) + if err != nil { + t.Fatalf(`failed at InsertContext, could not execute SQL statement %s`, err) + } + } +} + +// - Multi-row Insert.Insert, Insert.InsertContext + +// TestInsertManyRecsValues tests multi-row insert with every token type using slice-of-struct input +func TestInsertManyRecsValues(t *testing.T) { + for tt := range valuesTokenTypes { + UseTokenType = TokenType(tt) + ins := Insert{tbl, fiveRecsValues} + s := regexp.QuoteMeta(ins.SQL()) + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf(`failed to construct SQL mock %s`, err) + } + mock.ExpectPrepare(s) + mock.ExpectExec(s).WillReturnResult(sqlmock.NewResult(1, 1)) + _, err = ins.Insert(db) + if err != nil { + t.Fatalf(`failed at Insert, could not execute SQL statement %s`, err) + } + } +} + +// TestInsertManyRecsPointers tests multi-row insert with every token type using slice-of-struct-pointer input +func TestInsertManyRecsPointers(t *testing.T) { + for tt := range valuesTokenTypes { + UseTokenType = TokenType(tt) + ins := Insert{tbl, fiveRecsPointers} + s := regexp.QuoteMeta(ins.SQL()) + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf(`failed to construct SQL mock %s`, err) + } + mock.ExpectPrepare(s) + mock.ExpectExec(s).WillReturnResult(sqlmock.NewResult(1, 1)) + _, err = ins.Insert(db) + if err != nil { + t.Fatalf(`failed at Insert, could not execute SQL statement %s`, err) + } + } +} + +// TestInsertContextManyRecsValues tests multi-row insert with context with every token type using slice-of-struct input +func TestInsertContextManyRecsValues(t *testing.T) { + for tt := range valuesTokenTypes { + UseTokenType = TokenType(tt) + ins := Insert{tbl, fiveRecsValues} + s := regexp.QuoteMeta(ins.SQL()) + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf(`failed to construct SQL mock %s`, err) + } + mock.ExpectPrepare(s) + mock.ExpectExec(s).WillReturnResult(sqlmock.NewResult(1, 1)) + _, err = ins.InsertContext(context.Background(), db) + if err != nil { + t.Fatalf(`failed at InsertContext, could not execute SQL statement %s`, err) + } + } +} + +// TestInsertContextManyRecsPointers tests multi-row insert with context with every token type using slice-of-struct-pointer input +func TestInsertContextManyRecsPointers(t *testing.T) { + for tt := range valuesTokenTypes { + UseTokenType = TokenType(tt) + ins := Insert{tbl, fiveRecsPointers} + s := regexp.QuoteMeta(ins.SQL()) + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf(`failed to construct SQL mock %s`, err) + } + mock.ExpectPrepare(s) + mock.ExpectExec(s).WillReturnResult(sqlmock.NewResult(1, 1)) + _, err = ins.InsertContext(context.Background(), db) + if err != nil { + t.Fatalf(`failed at InsertContext, could not execute SQL statement %s`, err) + } + } +} diff --git a/pkg/testdata.go b/pkg/testdata.go new file mode 100644 index 0000000..e2cd891 --- /dev/null +++ b/pkg/testdata.go @@ -0,0 +1,58 @@ +package sqlinsert + +import "time" + +var valuesTokenTypes = []TokenType{ + QuestionMarkTokenType, + AtColumnNameTokenType, + OrdinalNumberTokenType, + ColonTokenType, +} + +type candyInsert struct { + Id string `col:"id"` + Name string `col:"candy_name"` + FormFactor string `col:"form_factor"` + Description string `col:"description"` + Mfr string `col:"manufacturer"` + Weight float64 `col:"weight_grams"` + Timestamp time.Time `col:"ts"` +} + +var tbl = `candy` + +var recValue = candyInsert{ + Id: `c0600afd-78a7-4a1a-87c5-1bc48cafd14e`, + Name: `Gougat`, + FormFactor: `Package`, + Description: `tastes like gopher feed`, + Mfr: `Gouggle`, + Weight: 1.16180, + Timestamp: time.Time{}, +} + +var recPointer = &candyInsert{ + Id: `c0600afd-78a7-4a1a-87c5-1bc48cafd14e`, + Name: `Gougat`, + FormFactor: `Package`, + Description: `tastes like gopher feed`, + Mfr: `Gouggle`, + Weight: 1.16180, + Timestamp: time.Time{}, +} + +var fiveRecsValues = []candyInsert{ + {`a`, `a`, `a`, `a`, `a`, 1.1, time.Time{}}, + {`b`, `b`, `b`, `b`, `b`, 2.1, time.Time{}}, + {`c`, `c`, `c`, `c`, `c`, 3.1, time.Time{}}, + {`d`, `d`, `d`, `d`, `d`, 4.1, time.Time{}}, + {`e`, `e`, `e`, `e`, `e`, 5.1, time.Time{}}, +} + +var fiveRecsPointers = []*candyInsert{ + {`a`, `a`, `a`, `a`, `a`, 1.1, time.Time{}}, + {`b`, `b`, `b`, `b`, `b`, 2.1, time.Time{}}, + {`c`, `c`, `c`, `c`, `c`, 3.1, time.Time{}}, + {`d`, `d`, `d`, `d`, `d`, 4.1, time.Time{}}, + {`e`, `e`, `e`, `e`, `e`, 5.1, time.Time{}}, +} diff --git a/pkg/token.go b/pkg/token.go new file mode 100644 index 0000000..41c5e92 --- /dev/null +++ b/pkg/token.go @@ -0,0 +1,69 @@ +package sqlinsert + +import ( + "fmt" + "reflect" + "strings" +) + +// UseStructTag specifies the struct tag key for the column name. Default is `col`. +var UseStructTag = `col` + +// TokenType represents a type of token in a SQL INSERT statement, whether column or value expression. +type TokenType int + +const ( + + /* COLUMN TokenType */ + + // ColumnNameTokenType uses the column name from the struct tag specified by UseStructTag. + // INSERT INTO tbl (foo, bar, ... baz) + ColumnNameTokenType TokenType = 0 + + /* VALUE TokenType */ + + // QuestionMarkTokenType uses question marks as value-tokens. + // VALUES (?, ?, ... ?) -- MySQL, SingleStore + QuestionMarkTokenType TokenType = 1 + + // AtColumnNameTokenType uses @ followed by the column name from the struct tag specified by UseStructTag. + // VALUES (@foo, @bar, ... @baz) -- MySQL, SingleStore + AtColumnNameTokenType TokenType = 2 + + // OrdinalNumberTokenType uses % plus the value of an ordered sequence of integers starting at 1. + // %1, %2, ... %n -- Postgres + OrdinalNumberTokenType TokenType = 3 + + // ColonTokenType uses : followed by the column name from the struct tag specified by UseStructTag. + // :foo, :bar, ... :baz -- Oracle + ColonTokenType TokenType = 4 +) + +// UseTokenType specifies the token type to use for values. Default is the question mark (`?`). +var UseTokenType = QuestionMarkTokenType + +// Tokenize translates struct fields into the tokens of SQL column or value expressions as a comma-separated list +// enclosed in parentheses. +func Tokenize(recordType reflect.Type, tokenType TokenType) string { + var b strings.Builder + b.WriteString(`(`) + for i := 0; i < recordType.NumField(); i++ { + switch tokenType { + case ColumnNameTokenType: + b.WriteString(recordType.Field(i).Tag.Get(UseStructTag)) + case QuestionMarkTokenType: + _, _ = fmt.Fprint(&b, `?`) + case AtColumnNameTokenType: + _, _ = fmt.Fprintf(&b, `@%s`, recordType.Field(i).Tag.Get(UseStructTag)) + case OrdinalNumberTokenType: + _, _ = fmt.Fprintf(&b, `$%d`, i+1) + case ColonTokenType: + _, _ = fmt.Fprintf(&b, `:%s`, recordType.Field(i).Tag.Get(UseStructTag)) + } + if i < recordType.NumField()-1 { + b.WriteString(`,`) + } + } + b.WriteString(`)`) + return b.String() +} diff --git a/pkg/token_test.go b/pkg/token_test.go new file mode 100644 index 0000000..73a8291 --- /dev/null +++ b/pkg/token_test.go @@ -0,0 +1,51 @@ +package sqlinsert + +import ( + "reflect" + "testing" +) + +func TestTokenizeColumnNameTokenType(t *testing.T) { + ins := Insert{tbl, recValue} + expected := `(id,candy_name,form_factor,description,manufacturer,weight_grams,ts)` + columnNames := Tokenize(reflect.TypeOf(ins.Data), ColumnNameTokenType) + if expected != columnNames { + t.Fatalf(`expected "%s", got "%s"`, expected, columnNames) + } +} + +func TestTokenizeQuestionMarkTokenType(t *testing.T) { + ins := Insert{tbl, recValue} + expected := `(?,?,?,?,?,?,?)` + bindParams := Tokenize(reflect.TypeOf(ins.Data), QuestionMarkTokenType) + if expected != bindParams { + t.Fatalf(`expected "%s", got "%s"`, expected, bindParams) + } +} + +func TestTokenizeAtColumnNameTokenType(t *testing.T) { + ins := Insert{tbl, recValue} + expected := `(@id,@candy_name,@form_factor,@description,@manufacturer,@weight_grams,@ts)` + bindParams := Tokenize(reflect.TypeOf(ins.Data), AtColumnNameTokenType) + if expected != bindParams { + t.Fatalf(`expected "%s", got "%s"`, expected, bindParams) + } +} + +func TestTokenizeOrdinalNumberTokenType(t *testing.T) { + ins := Insert{tbl, recValue} + expected := `($1,$2,$3,$4,$5,$6,$7)` + bindParams := Tokenize(reflect.TypeOf(ins.Data), OrdinalNumberTokenType) + if expected != bindParams { + t.Fatalf(`expected "%s", got "%s"`, expected, bindParams) + } +} + +func TestTokenizeColonTokenType(t *testing.T) { + ins := Insert{tbl, recValue} + expected := `(:id,:candy_name,:form_factor,:description,:manufacturer,:weight_grams,:ts)` + bindParams := Tokenize(reflect.TypeOf(ins.Data), ColonTokenType) + if expected != bindParams { + t.Fatalf(`expected "%s", got "%s"`, expected, bindParams) + } +} diff --git a/sqlinsert.go b/sqlinsert.go deleted file mode 100644 index 3e718b1..0000000 --- a/sqlinsert.go +++ /dev/null @@ -1,165 +0,0 @@ -package sqlinsert - -import ( - "context" - "database/sql" - "fmt" - "reflect" - "strings" -) - -// UseStructTag specifies the struct tag key for the column name. Default is `col`. -var UseStructTag = `col` - -// TokenType represents a type of token in a SQL INSERT statement, whether column or value expression. -type TokenType int - -const ( - - /* COLUMN TokenType */ - - // ColumnNameTokenType uses the column name from the struct tag specified by UseStructTag. - // INSERT INTO tbl (foo, bar, ... baz) - ColumnNameTokenType TokenType = 0 - - /* VALUE TokenType */ - - // QuestionMarkTokenType uses question marks as value-tokens. - // VALUES (?, ?, ... ?) -- MySQL, SingleStore - QuestionMarkTokenType TokenType = 1 - - // AtColumnNameTokenType uses @ followed by the column name from the struct tag specified by UseStructTag. - // VALUES (@foo, @bar, ... @baz) -- MySQL, SingleStore - AtColumnNameTokenType TokenType = 2 - - // OrdinalNumberTokenType uses % plus the value of an ordered sequence of integers starting at 1. - // %1, %2, ... %n -- Postgres - OrdinalNumberTokenType TokenType = 3 - - // ColonTokenType uses : followed by the column name from the struct tag specified by UseStructTag. - // :foo, :bar, ... :baz -- Oracle - ColonTokenType TokenType = 4 -) - -// UseTokenType specifies the token type to use for values. Default is the question mark (`?`). -var UseTokenType = QuestionMarkTokenType - -// InsertWith models functionality needed to execute a SQL INSERT statement with database/sql via sql.DB or sql.Tx. -// Note: sql.Conn is also supported, however, for PrepareContext and ExecContext only. -type InsertWith interface { - Prepare(query string) (*sql.Stmt, error) - PrepareContext(ctx context.Context, query string) (*sql.Stmt, error) - Exec(query string, args ...interface{}) (sql.Result, error) - ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error) -} - -// Inserter models functionality to produce a valid SQL INSERT statement with bind args. -type Inserter interface { - Tokenize(tokenType TokenType) string - Columns() string - Params() string - SQL() string - Args() []interface{} - Insert(with InsertWith) (*sql.Stmt, error) - InsertContext(ctx context.Context, with InsertWith) (*sql.Stmt, error) -} - -// Insert models data used to produce a valid SQL INSERT statement with bind args. -// Table is the table name. Record is the struct with column-name tagged fields and the data to be inserted. -// Private fields recordType and recordValue are used internally with reflection and interfaces to field values. -type Insert struct { - Table string - Record interface{} - recordType reflect.Type - recordValue reflect.Value -} - -// NewInsert builds a new Insert using a given table-name (string) and record data (struct). -// It is recommended that new NewInsert be used to build every new INSERT; however, if only Insert.Tokenize is -// needed, a "manually" build Insert will support tokenization as long as Insert.Table and Insert.Record are valid. -func NewInsert(Table string, Record interface{}) *Insert { - return &Insert{ - Table: Table, - Record: Record, - recordType: reflect.TypeOf(Record), - recordValue: reflect.ValueOf(Record), - } -} - -// Tokenize translates struct fields into the tokens of SQL column or value expressions. -func (ins *Insert) Tokenize(tokenType TokenType) string { - var b strings.Builder - for i := 0; i < ins.recordType.NumField(); i++ { - switch tokenType { - case ColumnNameTokenType: - b.WriteString(ins.recordType.Field(i).Tag.Get(UseStructTag)) - case QuestionMarkTokenType: - _, _ = fmt.Fprint(&b, `?`) - case AtColumnNameTokenType: - _, _ = fmt.Fprintf(&b, `@%s`, ins.recordType.Field(i).Tag.Get(UseStructTag)) - case OrdinalNumberTokenType: - _, _ = fmt.Fprintf(&b, `$%d`, i+1) - case ColonTokenType: - _, _ = fmt.Fprintf(&b, `:%s`, ins.recordType.Field(i).Tag.Get(UseStructTag)) - } - if i < ins.recordType.NumField()-1 { - b.WriteString(`, `) - } - } - return b.String() -} - -// Columns returns the comma-separated list of column names-as-tokens for the SQL INSERT statement. -func (ins *Insert) Columns() string { - return ins.Tokenize(ColumnNameTokenType) -} - -// Params returns the comma-separated list of bind param tokens for the SQL INSERT statement. -func (ins *Insert) Params() string { - return ins.Tokenize(UseTokenType) -} - -// SQL returns the full parameterized SQL INSERT statement. -func (ins *Insert) SQL() string { - var insertSQL strings.Builder - _, _ = fmt.Fprintf(&insertSQL, `INSERT INTO %s (%s) VALUES (%s)`, - ins.Table, ins.Columns(), ins.Params()) - return insertSQL.String() -} - -// Args returns the arguments to be bound in Insert() or the variadic Exec/ExecContext functions in database/sql. -func (ins *Insert) Args() []interface{} { - args := make([]interface{}, ins.recordType.NumField()) - for i := 0; i < ins.recordType.NumField(); i++ { - args[i] = ins.recordValue.Field(i).Interface() - } - return args -} - -// Insert prepares and executes a SQL INSERT statement on a *sql.DB, *sql.Tx, -// or other Inserter-compatible interface to Prepare and Exec. -func (ins *Insert) Insert(with InsertWith) (*sql.Stmt, error) { - stmt, err := with.Prepare(ins.SQL()) - if err != nil { - return nil, err - } - defer func(stmt *sql.Stmt) { - _ = stmt.Close() - }(stmt) - _, err = stmt.Exec(ins.Args()...) - return stmt, err -} - -// InsertContext prepares and executes a SQL INSERT statement on a *sql.DB, *sql.Tx, *sql.Conn, -// or other Inserter-compatible interface to PrepareContext and ExecContext. -func (ins *Insert) InsertContext(ctx context.Context, with InsertWith) (*sql.Stmt, error) { - stmt, err := with.Prepare(ins.SQL()) - if err != nil { - return nil, err - } - defer func(stmt *sql.Stmt) { - _ = stmt.Close() - }(stmt) - _, err = stmt.ExecContext(ctx, ins.Args()...) - return stmt, err -} diff --git a/sqlinsert_test.go b/sqlinsert_test.go deleted file mode 100644 index c8b8765..0000000 --- a/sqlinsert_test.go +++ /dev/null @@ -1,248 +0,0 @@ -package sqlinsert - -import ( - "context" - "database/sql" - "errors" - "fmt" - "github.com/DATA-DOG/go-sqlmock" - "reflect" - "regexp" - "testing" - "time" -) - -type candyInsert struct { - Id string `col:"id"` - Name string `col:"candy_name"` - FormFactor string `col:"form_factor"` - Description string `col:"description"` - Mfr string `col:"manufacturer"` - Weight float64 `col:"weight_grams"` - Timestamp time.Time `col:"ts"` -} - -var tbl = `candy` - -var rec = candyInsert{ - Id: `c0600afd-78a7-4a1a-87c5-1bc48cafd14e`, - Name: `Gougat`, - FormFactor: `Package`, - Description: `tastes like gopher feed`, - Mfr: `Gouggle`, - Weight: 1.16180, - Timestamp: time.Time{}, -} - -var valuesTokenTypes = []TokenType{ - QuestionMarkTokenType, - AtColumnNameTokenType, - OrdinalNumberTokenType, - ColonTokenType, -} - -type failingMock struct { -} - -func (*failingMock) Prepare(query string) (*sql.Stmt, error) { - _ = (func(string) interface{} { return nil })(query) - err := errors.New(`driver-level failure, cannot execute query`) - return nil, err -} - -func (*failingMock) PrepareContext(ctx context.Context, query string) (*sql.Stmt, error) { - _ = (func(context.Context, string) interface{} { return nil })(ctx, query) - err := errors.New(`driver-level failure, cannot execute query`) - return nil, err -} - -func (*failingMock) Exec(query string, args ...interface{}) (sql.Result, error) { - _ = (func(string, ...interface{}) interface{} { return nil })(query, args) - err := errors.New(`driver-level failure, cannot execute query`) - return nil, err -} - -func (*failingMock) ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error) { - _ = (func(context.Context, string, ...interface{}) interface{} { return nil })(ctx, query, args) - err := errors.New(`driver-level failure, cannot execute query`) - return nil, err -} - -func TestNewInsert(t *testing.T) { - naked := &Insert{ - Table: tbl, - Record: rec, - recordType: reflect.TypeOf(rec), - recordValue: reflect.ValueOf(rec), - } - built := NewInsert(tbl, rec) - if naked.Table != built.Table { - t.Fatalf(`expected NewInsert builder to return struct with table identical to that of test object`) - } - if !reflect.DeepEqual(naked.Record.(candyInsert), built.Record.(candyInsert)) { - t.Fatalf(`expected NewInsert builder to return struct with record values identical to those of test object`) - } -} - -func TestTokenizeColumnNameTokenType(t *testing.T) { - ins := NewInsert(tbl, rec) - expected := `id, candy_name, form_factor, description, manufacturer, weight_grams, ts` - columnNames := ins.Tokenize(ColumnNameTokenType) - if expected != columnNames { - t.Fatalf(`expected "%s", got "%s"`, expected, columnNames) - } -} - -func TestTokenizeQuestionMarkTokenType(t *testing.T) { - ins := NewInsert(tbl, rec) - expected := `?, ?, ?, ?, ?, ?, ?` - bindParams := ins.Tokenize(QuestionMarkTokenType) - if expected != bindParams { - t.Fatalf(`expected "%s", got "%s"`, expected, bindParams) - } -} - -func TestTokenizeAtColumnNameTokenType(t *testing.T) { - ins := NewInsert(tbl, rec) - expected := `@id, @candy_name, @form_factor, @description, @manufacturer, @weight_grams, @ts` - bindParams := ins.Tokenize(AtColumnNameTokenType) - if expected != bindParams { - t.Fatalf(`expected "%s", got "%s"`, expected, bindParams) - } -} - -func TestTokenizeOrdinalNumberTokenType(t *testing.T) { - ins := NewInsert(tbl, rec) - expected := `$1, $2, $3, $4, $5, $6, $7` - bindParams := ins.Tokenize(OrdinalNumberTokenType) - if expected != bindParams { - t.Fatalf(`expected "%s", got "%s"`, expected, bindParams) - } -} - -func TestTokenizeColonTokenType(t *testing.T) { - ins := NewInsert(tbl, rec) - expected := `:id, :candy_name, :form_factor, :description, :manufacturer, :weight_grams, :ts` - bindParams := ins.Tokenize(ColonTokenType) - if expected != bindParams { - t.Fatalf(`expected "%s", got "%s"`, expected, bindParams) - } -} - -func TestColumns(t *testing.T) { - ins := NewInsert(tbl, rec) - expected := `id, candy_name, form_factor, description, manufacturer, weight_grams, ts` - columns := ins.Columns() - if expected != columns { - t.Fatalf(`expected "%s", got "%s"`, expected, columns) - } -} - -func TestParams(t *testing.T) { - UseTokenType = QuestionMarkTokenType - ins := NewInsert(tbl, rec) - expected := `?, ?, ?, ?, ?, ?, ?` - params := ins.Params() - if expected != params { - t.Fatalf(`expected "%s", got "%s"`, expected, params) - } -} - -func TestSQL(t *testing.T) { - UseTokenType = OrdinalNumberTokenType - ins := NewInsert(tbl, rec) - expected := `INSERT INTO candy (id, candy_name, form_factor, description, manufacturer, weight_grams, ts) VALUES ($1, $2, $3, $4, $5, $6, $7)` - insertSQL := ins.SQL() - if expected != insertSQL { - t.Fatalf(`expected "%s", got "%s"`, expected, insertSQL) - } -} - -func TestArgs(t *testing.T) { - ins := NewInsert(tbl, rec) - expected := []interface{}{ - `c0600afd-78a7-4a1a-87c5-1bc48cafd14e`, - `Gougat`, - `Package`, - `tastes like gopher feed`, - `Gouggle`, - 1.1618, - time.Time{}, - } - args := ins.Args() - if !reflect.DeepEqual(expected, args) { - t.Fatalf(`expected "%s", got "%s"`, expected, args) - } -} - -func TestInsert(t *testing.T) { - for tt := range valuesTokenTypes { - UseTokenType = TokenType(tt) - ins := NewInsert(tbl, rec) - s := regexp.QuoteMeta(ins.SQL()) - db, mock, err := sqlmock.New() - if err != nil { - t.Fatalf(`failed to construct SQL mock %s`, err) - } - mock.ExpectPrepare(s) - mock.ExpectExec(s).WillReturnResult(sqlmock.NewResult(1, 1)) - _, err = ins.Insert(db) - if err != nil { - t.Fatalf(`failed at Insert, could not execute SQL statement %s`, err) - } - } -} - -func TestInsertContext(t *testing.T) { - for tt := range valuesTokenTypes { - UseTokenType = TokenType(tt) - ins := NewInsert(tbl, rec) - s := regexp.QuoteMeta(ins.SQL()) - db, mock, err := sqlmock.New() - if err != nil { - t.Fatalf(`failed to construct SQL mock %s`, err) - } - mock.ExpectPrepare(s) - mock.ExpectExec(s).WillReturnResult(sqlmock.NewResult(1, 1)) - _, err = ins.InsertContext(context.Background(), db) - if err != nil { - t.Fatalf(`failed at InsertContext, could not execute SQL statement %s`, err) - } - } -} - -func TestInsertError(t *testing.T) { - fm := &failingMock{} - UseTokenType = QuestionMarkTokenType - ins := NewInsert(tbl, rec) - s := regexp.QuoteMeta(ins.SQL()) - _, mock, err := sqlmock.New() - if err != nil { - t.Fatalf(`failed to construct SQL mock %s`, err) - } - mock.ExpectPrepare(s) - mock.ExpectExec(s).WillReturnResult(sqlmock.NewResult(1, 1)) - _, err = ins.Insert(fm) - if err == nil { - t.Fatalf(`expected Insert() to fail, but it succeeded`) - } - fmt.Printf(`simulation of driver-level error succeeded: %s`, err) -} - -func TestInsertContextError(t *testing.T) { - fm := &failingMock{} - UseTokenType = QuestionMarkTokenType - ins := NewInsert(tbl, rec) - s := regexp.QuoteMeta(ins.SQL()) - _, mock, err := sqlmock.New() - if err != nil { - t.Fatalf(`failed to construct SQL mock %s`, err) - } - mock.ExpectPrepare(s) - mock.ExpectExec(s).WillReturnResult(sqlmock.NewResult(1, 1)) - _, err = ins.InsertContext(context.Background(), fm) - if err == nil { - t.Fatalf(`expected InsertContext() to fail, but it succeeded`) - } - fmt.Printf(`simulation of driver-level error succeeded: %s`, err) -}