Merge pull request #2 from aldy505/feat/definition

feat: using a column definition builder
This commit is contained in:
Reinaldy Rafli 2021-07-21 17:36:10 +07:00 committed by GitHub
commit 0a8dbefad7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 346 additions and 102 deletions

152
README.md
View File

@ -2,9 +2,9 @@
[![Go Reference](https://pkg.go.dev/badge/github.com/aldy505/bob.svg)](https://pkg.go.dev/github.com/aldy505/bob) [![Go Report Card](https://goreportcard.com/badge/github.com/aldy505/bob)](https://goreportcard.com/report/github.com/aldy505/bob) ![GitHub](https://img.shields.io/github/license/aldy505/bob) [![CodeFactor](https://www.codefactor.io/repository/github/aldy505/bob/badge)](https://www.codefactor.io/repository/github/aldy505/bob) [![codecov](https://codecov.io/gh/aldy505/bob/branch/master/graph/badge.svg?token=Noeexg5xEJ)](https://codecov.io/gh/aldy505/bob) [![Codacy Badge](https://app.codacy.com/project/badge/Grade/9b78970127c74c1a923533e05f65848d)](https://www.codacy.com/gh/aldy505/bob/dashboard?utm_source=github.com&utm_medium=referral&utm_content=aldy505/bob&utm_campaign=Badge_Grade) [![Build test](https://github.com/aldy505/bob/actions/workflows/build.yml/badge.svg)](https://github.com/aldy505/bob/actions/workflows/build.yml) [![Test and coverage](https://github.com/aldy505/bob/actions/workflows/coverage.yml/badge.svg)](https://github.com/aldy505/bob/actions/workflows/coverage.yml)
I really need a create table SQL builder, and I can't find one. So, like everything else, I made one. Heavily inspired by [Squirrel](https://github.com/Masterminds/squirrel) and [Knex](https://knexjs.org/). And of course, I still use Squirrel for other types of queries (insert, select, and all), think this package as an extension for Squirrel.
Think of this as an extension of [Squirrel](https://github.com/Masterminds/squirrel) with functionability like [Knex](https://knexjs.org/). I still use Squirrel for other types of queries (insert, select, and all that), but I needed some SQL builder for create table and some other stuffs.
Oh, and of course, heavily inspired by Bob the Builder.
Oh, and of course, heavily inspired by [Bob the Builder](https://en.wikipedia.org/wiki/Bob_the_Builder).
```go
import "github.com/aldy505/bob"
@ -12,7 +12,103 @@ import "github.com/aldy505/bob"
## Usage
It's not ready for production yet. But, the API is probably close to how you'd do things on Squirrel. This is an example for using with pgx.
It's not ready for large-scale production yet (I've already using it on one of my projects). But, the API is probably close to how you'd do things on Squirrel.
### Create a table
```go
import "github.com/aldy505/bob"
func main() {
// Note that CREATE TABLE don't return args params.
sql, _, err := bob.
CreateTable("tableName").
// The first parameter is the column's name.
// The second parameters and so on forth are extras.
StringColumn("id", "NOT NULL", "PRIMARY KEY", "AUTOINCREMENT").
StringColumn("email", "NOT NULL", "UNIQUE").
// See the list of available column definition type through pkg.go.dev or scroll down below.
TextColumn("password").
// Or add your custom types
AddColumn(bob.ColumnDef{Name: "tableName", Type: "customType", Extras: []string{"NOT NULL"}}).
ToSql()
if err != nil {
// handle your error
}
}
```
Available column definition types:
* `StringColumn()` - Default to `VARCHAR(255)`
* `TextColumn()` - Default to `TEXT`
* `UUIDColumn()` - Defaults to `UUID`
* `BooleanColumn()` - Defaults to `BOOLEAN`
* `IntegerColumn()` - Defaults to `INTEGER`. Postgres and SQLite only.
* `IntColumn()` - Defaults to `INT`. MySQL and MSSQL only.
* `RealColumn()` - Defaults to `REAL`. Postgres, MSSQL, and SQLite only.
* `FloatColumn()` - Defaults to `FLOAT`. Postgres and SQLite only.
* `DateTimeColumn()` - Defaults to `DATETIME`.
* `TimeStampColumn()` - Defaults to `TIMESTAMP`.
* `TimeColumn()` - Defaults to `TIME`.
* `DateColumn()` - Defaults to `DATE`.
* `JSONColumn()` - Dafults to `JSON`. MySQL and Postgres only.
* `JSONBColumn()` - Defaults to `JSONB`. Postgres only.
* `BlobColumn()` - Defaults to `BLOB`. MySQL and SQLite only.
For any other types, please use `AddColumn()`.
Another builder of `bob.CreateTableIfNotExists()` is also available.
### Check if a table exists
```go
func main() {
sql, args, err := bob.HasTable("users").ToSql()
if err != nil {
log.Fatal(err)
}
}
```
### Check if a column exists
```go
func main() {
sql, args, err := bob.HasColumn("email").ToSql()
if err != nil {
log.Fatal(err)
}
}
```
### Placeholder format
Default placeholder is a question mark (MySQL-like). If you want to change it, simply use something like this:
```go
func main() {
// Option 1
sql, args, err := bob.HasTable("users").PlaceholderFormat(bob.Dollar).ToSql()
if err != nil {
log.Fatal(err)
}
// Option 2
sql, args, err = bob.HasTable("users").ToSql()
if err != nil {
log.Fatal(err)
}
correctPlaceholder := bob.ReplacePlaceholder(sql, bob.Dollar)
}
```
Available placeholder formats:
* `bob.Question` - `INSERT INTO "users" (name) VALUES (?)`
* `bob.Dollar` - `INSERT INTO "users" (name) VALUES ($1)`
* `bob.Colon` - `INSERT INTO "users" (name) VALUES (:1)`
* `bob.AtP` - `INSERT INTO "users" (name) VALUES (@p1)`
### With pgx (PostgreSQL)
```go
import (
@ -36,7 +132,7 @@ func main() {
var hasTableUsers bool
err = db.QueryRow(context.Background(), sql, args...).Scan(&hasTableUsers)
if err != nil {
if err.Error() == "no rows in result set" {
if err == bob.ErrEmptyTablePg {
hasTableUsers = false
} else {
log.Fatal(err)
@ -46,47 +142,61 @@ func main() {
if !hasTableUsers {
// Create "users" table
// Note that this will return multiple query in a single string.
sql, _, err := bob.CreateTable("users").
Columns("id", "email", "name", "password", "date").
Types("varchar(36)", "varchar(255)", "varchar(255)", "text", "date").
Primary("id").
Unique("email")
sql, _, err := bob.
CreateTable("users").
IntegerColumn("id", "PRIMARY KEY", "SERIAL").
StringColumn("name", "NOT NULL").
TextColumn("password", "NOT NULL").
DateColumn("created_at").
ToSql()
if err != nil {
log.Fatal(err)
}
// If you don't do this, you will get the error:
// ERROR: cannot insert multiple commands into a prepared statement (SQLSTATE 42601)
splitQuery := strings.Split(sql, ";")
for i := range splitQuery {
_, err = db.Query(context.Background(), splitQuery[i])
if err != nil {
log.Fatal(err)
}
}
// Create another table, this time with CREATE TABLE IF NOT EXISTS
sql, _, err := bob.CreateTableIfNotExists("inventory").
Columns("id", "userID", "items", "quantity").
Types("varchar(36)", "varchar(36)", "json", "int").
Primary("id").
sql, _, err := bob.
CreateTableIfNotExists("inventory").
UUIDColumn("id", "PRIMARY KEY").
IntegerColumn("userID", "FOREIGN KEY REFERENCES users(id)").
JSONColumn("items").
IntegerColumn("quantity").
ToSql()
if err != nil {
log.Fatal(err)
}
inventoryQuery := strings.Split(sql, ";")
for i := range inventoryQuery {
_, err = db.Query(context.Background(), inventoryQuery[i])
if err != nil {
log.Fatal(err)
}
}
}
}
```
## Features
* `bob.CreateTable(tableName)` - Basic SQL create table
* `bob.CreateTableIfNotExists(tableName)` - Create table if not exists
* `bob.HasTable(tableName)` - Checks if column exists (return error if false, check example above for error handling)
* `bob.HasColumn(columnName)` - Check if a column exists on current table
### TODO
Meaning these are some ideas for the future development of Bob.
* `bob.DropTable(tableName)` - Drop a table (`drop table "users"`)
* `bob.DropTableIfExists(tableName)` - Drop a table if exists (`drop table if exists "users"`)
* `bob.RenameTable(tableName)` - Rename a table (`rename table "users" to "old_users"`)
* `bob.Truncate(tableName)` - Truncate a table (`truncate "users"`)
* `bob.Upsert(tableName)` - UPSERT function (`insert into "users" ("name", "email") values (?, ?) on duplicate key update email = ?`)
* `bob.ExecWith()` - Just like Squirrel's [ExecWith](https://pkg.go.dev/github.com/Masterminds/squirrel?utm_source=godoc#ExecWith)
* `bob.Count(tableName, columnName)` - Count query (`select count("active") from "users"`)
## Contributing
Contributions are always welcome! As long as you add a test for your changes.

9
bob.go
View File

@ -1,6 +1,13 @@
package bob
import "github.com/lann/builder"
import (
"errors"
"github.com/lann/builder"
)
var ErrEmptyTable = errors.New("sql: no rows in result set")
var ErrEmptyTablePgx = errors.New("no rows in result set")
// BobBuilderType is the type for BobBuilder
type BobBuilderType builder.Builder

190
create.go
View File

@ -5,7 +5,6 @@ import (
"errors"
"strings"
"github.com/aldy505/bob/util"
"github.com/lann/builder"
)
@ -15,11 +14,13 @@ type createData struct {
TableName string
IfNotExists bool
Schema string
Columns []string
Types []string
Primary string
Unique string
NotNull []string
Columns []ColumnDef
}
type ColumnDef struct {
Name string
Type string
Extras []string
}
func init() {
@ -41,24 +42,149 @@ func (b CreateBuilder) WithSchema(name string) CreateBuilder {
return builder.Set(b, "Schema", name).(CreateBuilder)
}
// Columns sets the column names
func (b CreateBuilder) Columns(cols ...string) CreateBuilder {
return builder.Set(b, "Columns", cols).(CreateBuilder)
// StringColumn creates a column with VARCHAR(255) data type.
// For SQLite please refer to TextColumn.
func (b CreateBuilder) StringColumn(name string, extras ...string) CreateBuilder {
return builder.Append(b, "Columns", ColumnDef{
Name: name,
Type: "VARCHAR(255)",
Extras: extras,
}).(CreateBuilder)
}
// Types set a type for certain column
func (b CreateBuilder) Types(types ...string) CreateBuilder {
return builder.Set(b, "Types", types).(CreateBuilder)
// TextColumn creates a column with TEXT data type
func (b CreateBuilder) TextColumn(name string, extras ...string) CreateBuilder {
return builder.Append(b, "Columns", ColumnDef{
Name: name,
Type: "TEXT",
Extras: extras,
}).(CreateBuilder)
}
// Primary will set that column as the primary key for a table.
func (b CreateBuilder) Primary(column string) CreateBuilder {
return builder.Set(b, "Primary", column).(CreateBuilder)
// UUIDColumn only available for PostgreSQL
func (b CreateBuilder) UUIDColumn(name string, extras ...string) CreateBuilder {
return builder.Append(b, "Columns", ColumnDef{
Name: name,
Type: "UUID",
Extras: extras,
}).(CreateBuilder)
}
// Unique adds an unique index to a table over the given columns.
func (b CreateBuilder) Unique(column string) CreateBuilder {
return builder.Set(b, "Unique", column).(CreateBuilder)
// BooleanColumn only available for PostgreSQL
func (b CreateBuilder) BooleanColumn(name string, extras ...string) CreateBuilder {
return builder.Append(b, "Columns", ColumnDef{
Name: name,
Type: "BOOLEAN",
Extras: extras,
}).(CreateBuilder)
}
// IntegerColumn only available for PostgreSQL and SQLite.
// For MySQL and MSSQL, please refer to IntColumn,
func (b CreateBuilder) IntegerColumn(name string, extras ...string) CreateBuilder {
return builder.Append(b, "Columns", ColumnDef{
Name: name,
Type: "INTEGER",
Extras: extras,
}).(CreateBuilder)
}
// IntColumn only available for MySQL and MSSQL.
// For PostgreSQL and SQLite please refer to IntegerColumn.
func (b CreateBuilder) IntColumn(name string, extras ...string) CreateBuilder {
return builder.Append(b, "Columns", ColumnDef{
Name: name,
Type: "INT",
Extras: extras,
}).(CreateBuilder)
}
// RealColumn only available for MSSQL, PostgreSQL, and SQLite.
// For MySQL, please refer to FloatColumn, or create your own with AddColumn() with Type: "DOUBLE".
func (b CreateBuilder) RealColumn(name string, extras ...string) CreateBuilder {
return builder.Append(b, "Columns", ColumnDef{
Name: name,
Type: "REAL",
Extras: extras,
}).(CreateBuilder)
}
// FloatColumn only available for MySQL and MSSQL.
// For PostgreSQL and SQLite, please refer to RealColumn.
func (b CreateBuilder) FloatColumn(name string, extras ...string) CreateBuilder {
return builder.Append(b, "Columns", ColumnDef{
Name: name,
Type: "FLOAT",
Extras: extras,
}).(CreateBuilder)
}
func (b CreateBuilder) DateTimeColumn(name string, extras ...string) CreateBuilder {
return builder.Append(b, "Columns", ColumnDef{
Name: name,
Type: "DATETIME",
Extras: extras,
}).(CreateBuilder)
}
func (b CreateBuilder) TimeStampColumn(name string, extras ...string) CreateBuilder {
return builder.Append(b, "Columns", ColumnDef{
Name: name,
Type: "TIMESTAMP",
Extras: extras,
}).(CreateBuilder)
}
func (b CreateBuilder) TimeColumn(name string, extras ...string) CreateBuilder {
return builder.Append(b, "Columns", ColumnDef{
Name: name,
Type: "TIME",
Extras: extras,
}).(CreateBuilder)
}
func (b CreateBuilder) DateColumn(name string, extras ...string) CreateBuilder {
return builder.Append(b, "Columns", ColumnDef{
Name: name,
Type: "DATE",
Extras: extras,
}).(CreateBuilder)
}
// JSONColumn only available for MySQL and PostgreSQL.
// For MSSQL please use AddColumn(bob.ColumnDef{Name: "name", Type: "NVARCHAR(1000)"}).
// Not supported for SQLite.
func (b CreateBuilder) JSONColumn(name string, extras ...string) CreateBuilder {
return builder.Append(b, "Columns", ColumnDef{
Name: name,
Type: "JSON",
Extras: extras,
}).(CreateBuilder)
}
// JSONBColumn only available for PostgreSQL.
// For MySQL please refer to JSONColumn.
func (b CreateBuilder) JSONBColumn(name string, extras ...string) CreateBuilder {
return builder.Append(b, "Columns", ColumnDef{
Name: name,
Type: "JSONB",
Extras: extras,
}).(CreateBuilder)
}
// BlobColumn only available for MySQL and SQLite.
// For PostgreSQL and MSSQL, please use AddColumn(bob.ColumnDef{Name: "name", Type: "BYTEA"}).
func (b CreateBuilder) BlobColumn(name string, extras ...string) CreateBuilder {
return builder.Append(b, "Columns", ColumnDef{
Name: name,
Type: "BLOB",
Extras: extras,
}).(CreateBuilder)
}
// AddColumn sets custom columns
func (b CreateBuilder) AddColumn(column ColumnDef) CreateBuilder {
return builder.Append(b, "Columns", column).(CreateBuilder)
}
// ToSql returns 3 variables filled out with the correct values based on bindings, etc.
@ -74,8 +200,8 @@ func (d *createData) ToSql() (sqlStr string, args []interface{}, err error) {
return
}
if (len(d.Columns) != len(d.Types)) && len(d.Columns) > 0 {
err = errors.New("columns and types should have equal length")
if len(d.Columns) == 0 {
err = errors.New("a table should at least have one column")
return
}
@ -96,30 +222,18 @@ func (d *createData) ToSql() (sqlStr string, args []interface{}, err error) {
var columnTypes []string
for i := 0; i < len(d.Columns); i++ {
columnTypes = append(columnTypes, "\""+d.Columns[i]+"\" "+d.Types[i])
var column []string
column = append(column, "\""+d.Columns[i].Name+"\" "+d.Columns[i].Type)
if len(d.Columns[i].Extras) > 0 {
column = append(column, strings.Join(d.Columns[i].Extras, " "))
}
columnTypes = append(columnTypes, strings.Join(column, " "))
}
sql.WriteString("(")
sql.WriteString(strings.Join(columnTypes, ", "))
sql.WriteString(");")
if d.Primary != "" {
if !util.IsIn(d.Columns, d.Primary) {
err = errors.New("supplied primary column name doesn't exists on columns")
return
}
sql.WriteString(" ")
sql.WriteString("ALTER TABLE \"" + d.TableName + "\" ADD PRIMARY KEY (\"" + d.Primary + "\");")
}
if d.Unique != "" {
if !util.IsIn(d.Columns, d.Unique) {
err = errors.New("supplied unique column name doesn't exists on columns")
return
}
sql.WriteString(" ")
sql.WriteString("ALTER TABLE \"" + d.TableName + "\" ADD UNIQUE (\"" + d.Unique + "\");")
}
sqlStr = sql.String()
return
}

View File

@ -7,81 +7,94 @@ import (
)
func TestCreate(t *testing.T) {
t.Run("should return correct sql string with basic columns and types", func(t *testing.T) {
sql, _, err := bob.CreateTable("users").Columns("name", "password", "date").Types("varchar(255)", "text", "date").ToSql()
t.Run("should return correct sql string with all columns and types", func(t *testing.T) {
sql, _, err := bob.
CreateTable("users").
UUIDColumn("uuid").
StringColumn("string").
TextColumn("text").
DateColumn("date").
BooleanColumn("boolean").
IntegerColumn("integer").
IntColumn("int").
TimeStampColumn("timestamp").
TimeColumn("time").
DateColumn("date").
DateTimeColumn("datetime").
JSONColumn("json").
JSONBColumn("jsonb").
BlobColumn("blob").
RealColumn("real").
FloatColumn("float").
AddColumn(bob.ColumnDef{Name: "custom", Type: "custom"}).
ToSql()
if err != nil {
t.Fatal(err.Error())
}
result := "CREATE TABLE \"users\" (\"name\" varchar(255), \"password\" text, \"date\" date);"
result := "CREATE TABLE \"users\" (\"uuid\" UUID, \"string\" VARCHAR(255), \"text\" TEXT, \"date\" DATE, \"boolean\" BOOLEAN, \"integer\" INTEGER, \"int\" INT, \"timestamp\" TIMESTAMP, \"time\" TIME, \"date\" DATE, \"datetime\" DATETIME, \"json\" JSON, \"jsonb\" JSONB, \"blob\" BLOB, \"real\" REAL, \"float\" FLOAT, \"custom\" custom);"
if sql != result {
t.Fatal("sql is not equal to result:", sql)
}
})
t.Run("should return correct sql with primary key and unique key", func(t *testing.T) {
t.Run("should return correct sql with extras", func(t *testing.T) {
sql, _, err := bob.CreateTable("users").
Columns("id", "name", "email", "password", "date").
Types("uuid", "varchar(255)", "varchar(255)", "text", "date").
Primary("id").
Unique("email").
UUIDColumn("id", "PRIMARY KEY").
StringColumn("email", "NOT NULL", "UNIQUE").
ToSql()
if err != nil {
t.Fatal(err.Error())
}
result := "CREATE TABLE \"users\" (\"id\" uuid, \"name\" varchar(255), \"email\" varchar(255), \"password\" text, \"date\" date); ALTER TABLE \"users\" ADD PRIMARY KEY (\"id\"); ALTER TABLE \"users\" ADD UNIQUE (\"email\");"
result := "CREATE TABLE \"users\" (\"id\" UUID PRIMARY KEY, \"email\" VARCHAR(255) NOT NULL UNIQUE);"
if sql != result {
t.Fatal("sql is not equal to result:", sql)
}
})
t.Run("should be able to have a schema name", func(t *testing.T) {
sql, _, err := bob.CreateTable("users").WithSchema("private").Columns("name", "password", "date").Types("varchar(255)", "text", "date").ToSql()
sql, _, err := bob.
CreateTable("users").
WithSchema("private").
StringColumn("name").
ToSql()
if err != nil {
t.Fatal(err.Error())
}
result := "CREATE TABLE \"private\".\"users\" (\"name\" varchar(255), \"password\" text, \"date\" date);"
result := "CREATE TABLE \"private\".\"users\" (\"name\" VARCHAR(255));"
if sql != result {
t.Fatal("sql is not equal to result:", sql)
}
})
t.Run("should emit error on unmatched column and types length", func(t *testing.T) {
_, _, err := bob.CreateTable("users").
Columns("id", "name", "email", "password", "date").
Types("uuid", "varchar(255)", "varchar(255)", "date").
ToSql()
if err.Error() != "columns and types should have equal length" {
t.Fatal("should throw an error, it didn't:", err.Error())
}
})
t.Run("should emit error on empty table name", func(t *testing.T) {
_, _, err := bob.CreateTable("").Columns("name").Types("text").ToSql()
_, _, err := bob.
CreateTable("").
StringColumn("name").
ToSql()
if err.Error() != "create statements must specify a table" {
t.Fatal("should throw an error, it didn't:", err.Error())
}
})
t.Run("should emit error for primary key not in columns", func(t *testing.T) {
_, _, err := bob.CreateTable("users").Columns("name").Types("text").Primary("id").ToSql()
if err.Error() != "supplied primary column name doesn't exists on columns" {
t.Fatal("should throw an error, it didn't:", err.Error())
}
})
t.Run("should emit error for unique key not in columns", func(t *testing.T) {
_, _, err := bob.CreateTable("users").Columns("name").Types("text").Unique("id").ToSql()
if err.Error() != "supplied unique column name doesn't exists on columns" {
t.Run("should emit error if no column were specified", func(t *testing.T) {
_, _, err := bob.
CreateTable("users").
ToSql()
if err.Error() != "a table should at least have one column" {
t.Fatal("should throw an error, it didn't:", err.Error())
}
})
t.Run("should emit create if not exists", func(t *testing.T) {
sql, _, err := bob.CreateTableIfNotExists("users").Columns("name").Types("text").ToSql()
sql, _, err := bob.
CreateTableIfNotExists("users").
TextColumn("name").
ToSql()
if err != nil {
t.Fatal(err.Error())
}
result := "CREATE TABLE IF NOT EXISTS \"users\" (\"name\" text);"
result := "CREATE TABLE IF NOT EXISTS \"users\" (\"name\" TEXT);"
if sql != result {
t.Fatal("sql is not equal to result: ", sql)
}

View File

@ -28,7 +28,7 @@ func ReplacePlaceholder(sql string, format string) string {
format = Question
}
if format == Dollar || format == Colon {
if format == Dollar || format == Colon || format == AtP {
separate := strings.SplitAfter(sql, "?")
for i := 0; i < len(separate); i++ {
separate[i] = strings.Replace(separate[i], "?", format+strconv.Itoa(i+1), 1)

View File

@ -30,7 +30,7 @@ func TestReplacePlaceholder(t *testing.T) {
t.Run("should be able to replace placeholder to @p", func(t *testing.T) {
sql := "INSERT INTO table_name (`col1`, `col2`, `col3`) VALUES (?, ?, ?), (?, ?, ?), (?, ?, ?);"
result := bob.ReplacePlaceholder(sql, bob.AtP)
should := "INSERT INTO table_name (`col1`, `col2`, `col3`) VALUES (@p, @p, @p), (@p, @p, @p), (@p, @p, @p);"
should := "INSERT INTO table_name (`col1`, `col2`, `col3`) VALUES (@p1, @p2, @p3), (@p4, @p5, @p6), (@p7, @p8, @p9);"
if result != should {
t.Fatal("result string doesn't match:", result)