Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 44 additions & 2 deletions builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,11 @@ func (s *StatementBuilder) InsertRecord(record interface{}, optTableName ...stri
return InsertBuilder{InsertBuilder: insert, err: wrapErr(err)}
}
if len(cols) == 0 {
return InsertBuilder{InsertBuilder: insert, err: wrapErr(fmt.Errorf("Map returned no columns for %T; for an all-default INSERT use sq.Expr(\"INSERT INTO %s DEFAULT VALUES\")", record, tableName))}
hint := `SQL.InsertDefaults("<table>")`
if tableName != "" {
hint = fmt.Sprintf("SQL.InsertDefaults(%q)", tableName)
}
return InsertBuilder{InsertBuilder: insert, err: wrapErr(fmt.Errorf("Map returned no columns for %T; for an all-default INSERT use %s", record, hint))}
}

return InsertBuilder{InsertBuilder: insert.Into(tableName).Columns(cols...).Values(vals...)}
Expand Down Expand Up @@ -64,7 +68,7 @@ func (s StatementBuilder) InsertRecords(recordsSlice interface{}, optTableName .
return InsertBuilder{InsertBuilder: insert, err: wrapErr(err)}
}
if len(cols) == 0 {
return InsertBuilder{InsertBuilder: insert, err: wrapErr(fmt.Errorf("Map returned no columns for record %d (%T); for an all-default INSERT use sq.Expr", i, record))}
return InsertBuilder{InsertBuilder: insert, err: wrapErr(fmt.Errorf("Map returned no columns for record %d (%T); for an all-default INSERT use SQL.InsertDefaults (single-row only)", i, record))}
}

if i == 0 {
Expand All @@ -84,6 +88,44 @@ func (s StatementBuilder) InsertRecords(recordsSlice interface{}, optTableName .
return InsertBuilder{InsertBuilder: insert.Into(tableName)}
}

// InsertDefaults builds INSERT INTO <table> DEFAULT VALUES; table must be non-empty.
func (s StatementBuilder) InsertDefaults(table string) DefaultValuesBuilder {
if table == "" {
// raw error; Querier.wrapErr applies the pgkit: prefix at use time.
return DefaultValuesBuilder{err: fmt.Errorf("insert statements must specify a table")}
}
return DefaultValuesBuilder{table: table}
}

// DefaultValuesBuilder is the sq.Sqlizer returned by InsertDefaults.
type DefaultValuesBuilder struct {
table string
suffix string
err error
}

func (b DefaultValuesBuilder) ToSql() (string, []any, error) {
if b.err != nil {
return "", nil, b.err
}
// Defensive: a zero-value DefaultValuesBuilder bypasses the InsertDefaults
// constructor's table check, so re-validate here.
if b.table == "" {
return "", nil, fmt.Errorf("insert statements must specify a table")
}
return "INSERT INTO " + b.table + " DEFAULT VALUES" + b.suffix, nil, nil
}

// Suffix appends literal SQL; no placeholder rewriting, use sq.Expr for that.
func (b DefaultValuesBuilder) Suffix(sql string) DefaultValuesBuilder {
if b.err != nil || sql == "" {
return b
}
return DefaultValuesBuilder{table: b.table, suffix: b.suffix + " " + sql}
}

func (b DefaultValuesBuilder) Err() error { return b.err }

func (s StatementBuilder) UpdateRecord(record interface{}, whereExpr sq.Eq, optTableName ...string) UpdateBuilder {
return s.UpdateRecordColumns(record, whereExpr, nil, optTableName...)
}
Expand Down
82 changes: 75 additions & 7 deletions builder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,22 +45,90 @@ func TestInsertRecords_UniformShape(t *testing.T) {
require.NoError(t, b.Err())
}

func TestInsertRecord_EmptyColumnsRejected(t *testing.T) {
// All fields tagged ,omitzero (or ,omitempty) and all zero leaves
// Map with no columns. Squirrel would emit invalid INSERT INTO t
// VALUES (); fail fast at build time and point at sq.Expr as the
// escape for the all-default INSERT case. Tracked in goware/pgkit#51.
func TestInsertDefaults_PlainSQL(t *testing.T) {
sb := &pgkit.StatementBuilder{StatementBuilderType: sq.StatementBuilder.PlaceholderFormat(sq.Dollar)}
b := sb.InsertDefaults("items")
require.NoError(t, b.Err())
sql, args, err := b.ToSql()
require.NoError(t, err)
assert.Equal(t, "INSERT INTO items DEFAULT VALUES", sql)
assert.Empty(t, args)
}

func TestInsertDefaults_WithReturning(t *testing.T) {
sb := &pgkit.StatementBuilder{StatementBuilderType: sq.StatementBuilder.PlaceholderFormat(sq.Dollar)}
b := sb.InsertDefaults("items").Suffix(`RETURNING "id"`)
require.NoError(t, b.Err())
sql, _, err := b.ToSql()
require.NoError(t, err)
assert.Equal(t, `INSERT INTO items DEFAULT VALUES RETURNING "id"`, sql)
}

func TestInsertDefaults_MultipleSuffix(t *testing.T) {
sb := &pgkit.StatementBuilder{StatementBuilderType: sq.StatementBuilder.PlaceholderFormat(sq.Dollar)}
b := sb.InsertDefaults("items").
Suffix("ON CONFLICT (id) DO NOTHING").
Suffix(`RETURNING "id"`)
sql, _, err := b.ToSql()
require.NoError(t, err)
assert.Equal(t, `INSERT INTO items DEFAULT VALUES ON CONFLICT (id) DO NOTHING RETURNING "id"`, sql)
}

func TestInsertDefaults_OnConflictDoUpdateExcluded(t *testing.T) {
// EXCLUDED-based upsert is the realistic conflict shape: literal SQL,
// no placeholders. Proves Suffix covers the common case without sq.Expr.
sb := &pgkit.StatementBuilder{StatementBuilderType: sq.StatementBuilder.PlaceholderFormat(sq.Dollar)}
b := sb.InsertDefaults("items").Suffix("ON CONFLICT (id) DO UPDATE SET updated_at = EXCLUDED.updated_at RETURNING id")
sql, _, err := b.ToSql()
require.NoError(t, err)
assert.Equal(t, "INSERT INTO items DEFAULT VALUES ON CONFLICT (id) DO UPDATE SET updated_at = EXCLUDED.updated_at RETURNING id", sql)
}

func TestInsertDefaults_EmptyTableErrors(t *testing.T) {
// Build-time failure (not exec-time) + raw error (Querier owns the
// pgkit: prefix; double-wrapping would surface "pgkit: pgkit: ...").
sb := &pgkit.StatementBuilder{StatementBuilderType: sq.StatementBuilder.PlaceholderFormat(sq.Dollar)}
b := sb.InsertDefaults("")
require.Error(t, b.Err())
assert.Contains(t, b.Err().Error(), "table")
_, _, err := b.ToSql()
require.Error(t, err)
assert.NotContains(t, err.Error(), "pgkit: pgkit:")
}

func TestInsertDefaults_ZeroValueErrors(t *testing.T) {
// Direct zero-value construction bypasses the InsertDefaults factory's
// table check; ToSql must still error rather than emit invalid SQL.
var b pgkit.DefaultValuesBuilder
_, _, err := b.ToSql()
require.Error(t, err)
assert.Contains(t, err.Error(), "table")
}

func TestInsertDefaults_EmptySuffixIsNoop(t *testing.T) {
// Conditional-suffix callers (b.Suffix(returningClause) with returningClause
// occasionally "") shouldn't get a trailing space that breaks SQL-string
// snapshot tests.
sb := &pgkit.StatementBuilder{StatementBuilderType: sq.StatementBuilder.PlaceholderFormat(sq.Dollar)}
b := sb.InsertDefaults("items").Suffix("")
sql, _, err := b.ToSql()
require.NoError(t, err)
assert.Equal(t, "INSERT INTO items DEFAULT VALUES", sql)
}

func TestInsertRecord_AllDefaultsErrorHintsAtInsertDefaults(t *testing.T) {
type Item struct {
Tags []string `db:"tags,omitzero"`
}
sb := &pgkit.StatementBuilder{StatementBuilderType: sq.StatementBuilder.PlaceholderFormat(sq.Dollar)}
b := sb.InsertRecord(&Item{}, "items")
require.Error(t, b.Err())
assert.Contains(t, b.Err().Error(), "no columns")
assert.Contains(t, b.Err().Error(), "sq.Expr")
assert.Contains(t, b.Err().Error(), `SQL.InsertDefaults("items")`)
}

func TestInsertRecords_EmptyColumnsRejected(t *testing.T) {
// Multi-row INSERT ... DEFAULT VALUES is not valid PG; the batch path
// rejects all-default records and points at the single-row InsertRecord.
type Item struct {
Tags []string `db:"tags,omitzero"`
}
Expand Down
19 changes: 19 additions & 0 deletions tests/pgkit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1165,3 +1165,22 @@ func connectToDb(conf pgkit.Config) (*pgkit.DB, error) {
}
return dbClient, err
}

func TestInsertDefaultsRoundTrip(t *testing.T) {
// Hits PG (not just SQL string snapshots) to catch syntax issues
// the builder might emit but squirrel can't validate.
truncateTable(t, "default_only")

var id int64
err := DB.Query.QueryRow(
context.Background(),
DB.SQL.InsertDefaults("default_only").Suffix(`RETURNING "id"`),
).Scan(&id)
require.NoError(t, err)
require.NotZero(t, id)

rows, err := DB.Conn.Query(context.Background(), "SELECT id FROM default_only WHERE id = $1", id)
require.NoError(t, err)
defer rows.Close()
require.True(t, rows.Next(), "row should exist")
}
6 changes: 6 additions & 0 deletions tests/testdata/pgkit_test_db.sql
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,9 @@ CREATE TABLE stats (
big_num NUMERIC(78,0) NOT NULL, -- representing a big.Int runtime type
rating NUMERIC(78,0) NULL -- representing a nullable big.Int runtime type
);

-- every column DB-defaulted, so DEFAULT VALUES is the only insert path.
CREATE TABLE default_only (
id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL
);
Loading