diff --git a/packages/db/migrations/20260314120000_fix_snapshots_metadata_sql_null_trigger.sql b/packages/db/migrations/20260314120000_fix_snapshots_metadata_sql_null_trigger.sql new file mode 100644 index 0000000000..437c56c521 --- /dev/null +++ b/packages/db/migrations/20260314120000_fix_snapshots_metadata_sql_null_trigger.sql @@ -0,0 +1,31 @@ +-- +goose Up + +-- Update the trigger to also handle SQL NULL (not just JSON 'null' literal). +-- The NOT NULL constraint on metadata rejects SQL NULLs, but the BEFORE trigger +-- runs first and can convert them to '{}' before the constraint is checked. +-- +goose StatementBegin +CREATE OR REPLACE FUNCTION fix_snapshots_metadata_json_null() +RETURNS trigger AS $$ +BEGIN + IF NEW.metadata IS NULL OR NEW.metadata = 'null'::jsonb THEN + NEW.metadata := '{}'::jsonb; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; +-- +goose StatementEnd + +-- +goose Down + +-- Restore original trigger that only handles JSON null. +-- +goose StatementBegin +CREATE OR REPLACE FUNCTION fix_snapshots_metadata_json_null() +RETURNS trigger AS $$ +BEGIN + IF NEW.metadata = 'null'::jsonb THEN + NEW.metadata := '{}'::jsonb; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; +-- +goose StatementEnd diff --git a/packages/db/pkg/types/types.go b/packages/db/pkg/types/types.go index e9cede9553..54071ad12c 100644 --- a/packages/db/pkg/types/types.go +++ b/packages/db/pkg/types/types.go @@ -1,7 +1,37 @@ package types +import "encoding/json" + type JSONBStringMap map[string]string +// MarshalJSON ensures a nil map serializes as "{}" instead of "null", +// preventing SQL NULL when pgx encodes the value for jsonb columns. +func (m JSONBStringMap) MarshalJSON() ([]byte, error) { + if m == nil { + return []byte("{}"), nil + } + + return json.Marshal(map[string]string(m)) +} + +// UnmarshalJSON ensures JSON null deserializes as an empty map instead of nil. +func (m *JSONBStringMap) UnmarshalJSON(data []byte) error { + if string(data) == "null" { + *m = JSONBStringMap{} + + return nil + } + + var raw map[string]string + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + *m = JSONBStringMap(raw) + + return nil +} + type BuildReason struct { // Message with the status reason, currently reporting only for error status Message string `json:"message"` diff --git a/packages/db/pkg/types/types_test.go b/packages/db/pkg/types/types_test.go new file mode 100644 index 0000000000..f97f38a888 --- /dev/null +++ b/packages/db/pkg/types/types_test.go @@ -0,0 +1,93 @@ +package types + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestJSONBStringMap_MarshalJSON_Nil(t *testing.T) { + t.Parallel() + + var m JSONBStringMap + data, err := json.Marshal(m) + require.NoError(t, err) + assert.Equal(t, "{}", string(data)) +} + +func TestJSONBStringMap_MarshalJSON_Empty(t *testing.T) { + t.Parallel() + + m := JSONBStringMap{} + data, err := json.Marshal(m) + require.NoError(t, err) + assert.Equal(t, "{}", string(data)) +} + +func TestJSONBStringMap_MarshalJSON_WithValues(t *testing.T) { + t.Parallel() + + m := JSONBStringMap{"key": "value"} + data, err := json.Marshal(m) + require.NoError(t, err) + assert.JSONEq(t, `{"key":"value"}`, string(data)) +} + +func TestJSONBStringMap_UnmarshalJSON_Null(t *testing.T) { + t.Parallel() + + var m JSONBStringMap + err := json.Unmarshal([]byte("null"), &m) + require.NoError(t, err) + assert.NotNil(t, m) + assert.Empty(t, m) +} + +func TestJSONBStringMap_UnmarshalJSON_EmptyObject(t *testing.T) { + t.Parallel() + + var m JSONBStringMap + err := json.Unmarshal([]byte("{}"), &m) + require.NoError(t, err) + assert.NotNil(t, m) + assert.Empty(t, m) +} + +func TestJSONBStringMap_UnmarshalJSON_WithValues(t *testing.T) { + t.Parallel() + + var m JSONBStringMap + err := json.Unmarshal([]byte(`{"key":"value"}`), &m) + require.NoError(t, err) + assert.Equal(t, JSONBStringMap{"key": "value"}, m) +} + +func TestJSONBStringMap_RoundTrip(t *testing.T) { + t.Parallel() + + original := JSONBStringMap{"foo": "bar", "baz": "qux"} + data, err := json.Marshal(original) + require.NoError(t, err) + + var decoded JSONBStringMap + err = json.Unmarshal(data, &decoded) + require.NoError(t, err) + assert.Equal(t, original, decoded) +} + +func TestJSONBStringMap_NilRoundTrip(t *testing.T) { + t.Parallel() + + var original JSONBStringMap // nil + data, err := json.Marshal(original) + require.NoError(t, err) + assert.Equal(t, "{}", string(data)) + + var decoded JSONBStringMap + err = json.Unmarshal(data, &decoded) + require.NoError(t, err) + assert.NotNil(t, decoded) + assert.Empty(t, decoded) +}