Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -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
30 changes: 30 additions & 0 deletions packages/db/pkg/types/types.go
Original file line number Diff line number Diff line change
@@ -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
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The MarshalJSON override affects all JSON serialization of JSONBStringMap, not only DB persistence. Any API response structs embedding this type that previously returned null for absent metadata will now return {}. Clients distinguishing null (field absent/not set) from {} (explicitly empty) will see a behavioral change. Worth verifying no external API consumers rely on the null sentinel before merging.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

its ok


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"`
Expand Down
93 changes: 93 additions & 0 deletions packages/db/pkg/types/types_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading