diff --git a/builder.go b/builder.go index 20a39e2..561bb7e 100644 --- a/builder.go +++ b/builder.go @@ -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("")` + 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...)} @@ -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 { @@ -84,6 +88,44 @@ func (s StatementBuilder) InsertRecords(recordsSlice interface{}, optTableName . return InsertBuilder{InsertBuilder: insert.Into(tableName)} } +// InsertDefaults builds INSERT INTO
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...) } diff --git a/builder_test.go b/builder_test.go index d72df6e..0b5de8b 100644 --- a/builder_test.go +++ b/builder_test.go @@ -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"` } diff --git a/tests/pgkit_test.go b/tests/pgkit_test.go index c6c8530..517b646 100644 --- a/tests/pgkit_test.go +++ b/tests/pgkit_test.go @@ -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") +} diff --git a/tests/testdata/pgkit_test_db.sql b/tests/testdata/pgkit_test_db.sql index 406d655..a4cba0c 100644 --- a/tests/testdata/pgkit_test_db.sql +++ b/tests/testdata/pgkit_test_db.sql @@ -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 +);