diff --git a/README.md b/README.md index 5055344..d07a757 100644 --- a/README.md +++ b/README.md @@ -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) - } + _, 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) - } + _, 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. diff --git a/bob.go b/bob.go index 420e1e9..c25e097 100644 --- a/bob.go +++ b/bob.go @@ -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 diff --git a/create.go b/create.go index 4017b65..1748e03 100644 --- a/create.go +++ b/create.go @@ -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 } diff --git a/create_test.go b/create_test.go index 988f6e3..3c92425 100644 --- a/create_test.go +++ b/create_test.go @@ -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) } diff --git a/placeholder.go b/placeholder.go index e812c7c..d728609 100644 --- a/placeholder.go +++ b/placeholder.go @@ -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) diff --git a/placeholder_test.go b/placeholder_test.go index 01075a5..0cebb4a 100644 --- a/placeholder_test.go +++ b/placeholder_test.go @@ -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)