Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 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
75 changes: 4 additions & 71 deletions flow/connectors/clickhouse/clickhouse.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import (
"os"
"path/filepath"
"slices"
"strings"
"time"

"github.com/ClickHouse/clickhouse-go/v2"
Expand All @@ -24,7 +23,6 @@ import (
"github.com/PeerDB-io/peerdb/flow/internal"
peerdb_clickhouse "github.com/PeerDB-io/peerdb/flow/pkg/clickhouse"
"github.com/PeerDB-io/peerdb/flow/shared"
"github.com/PeerDB-io/peerdb/flow/shared/types"
)

type ClickHouseConnector struct {
Expand Down Expand Up @@ -401,79 +399,14 @@ func GetTableSchemaForTable(tm *protos.TableMapping, columns []driver.ColumnType
continue
}

var qkind types.QValueKind
switch column.DatabaseTypeName() {
case "String", "Nullable(String)", "LowCardinality(String)", "LowCardinality(Nullable(String))":
qkind = types.QValueKindString
case "Bool", "Nullable(Bool)":
qkind = types.QValueKindBoolean
case "Int8", "Nullable(Int8)":
qkind = types.QValueKindInt8
case "Int16", "Nullable(Int16)":
qkind = types.QValueKindInt16
case "Int32", "Nullable(Int32)":
qkind = types.QValueKindInt32
case "Int64", "Nullable(Int64)":
qkind = types.QValueKindInt64
case "Int256", "Nullable(Int256)":
qkind = types.QValueKindInt256
case "UInt8", "Nullable(UInt8)":
qkind = types.QValueKindUInt8
case "UInt16", "Nullable(UInt16)":
qkind = types.QValueKindUInt16
case "UInt32", "Nullable(UInt32)":
qkind = types.QValueKindUInt32
case "UInt64", "Nullable(UInt64)":
qkind = types.QValueKindUInt64
case "UInt256", "Nullable(UInt256)":
qkind = types.QValueKindUInt256
case "UUID", "Nullable(UUID)":
qkind = types.QValueKindUUID
case "DateTime64(6)", "Nullable(DateTime64(6))", "DateTime64(9)", "Nullable(DateTime64(9))":
qkind = types.QValueKindTimestamp
case "Time64(6)", "Nullable(Time64(6))":
qkind = types.QValueKindTime
case "Date32", "Nullable(Date32)":
qkind = types.QValueKindDate
case "Float32", "Nullable(Float32)":
qkind = types.QValueKindFloat32
case "Float64", "Nullable(Float64)":
qkind = types.QValueKindFloat64
case "Array(Int32)":
qkind = types.QValueKindArrayInt32
case "Array(Float32)":
qkind = types.QValueKindArrayFloat32
case "Array(Float64)":
qkind = types.QValueKindArrayFloat64
case "Array(String)", "Array(LowCardinality(String))":
qkind = types.QValueKindArrayString
case "Array(UUID)":
qkind = types.QValueKindArrayUUID
case "Array(DateTime64(6))":
qkind = types.QValueKindArrayTimestamp
case "Array(Int64)":
qkind = types.QValueKindArrayInt64
case "Array(Bool)":
qkind = types.QValueKindArrayBoolean
case "Array(Date)":
qkind = types.QValueKindArrayDate
case "JSON":
qkind = types.QValueKindJSON
default:
if strings.Contains(column.DatabaseTypeName(), "Decimal") {
if strings.HasPrefix(column.DatabaseTypeName(), "Array(") {
qkind = types.QValueKindArrayNumeric
} else {
qkind = types.QValueKindNumeric
}
} else {
return nil, fmt.Errorf("failed to resolve QValueKind for %s", column.DatabaseTypeName())
}
qkind, err := peerdb_clickhouse.QValueKindForType(column.DatabaseTypeName())
if err != nil {
return nil, err
}

colFields = append(colFields, &protos.FieldDescription{
Name: column.Name(),
Type: string(qkind),
Type: qkind,
TypeModifier: -1,
Nullable: column.Nullable(),
})
Expand Down
137 changes: 137 additions & 0 deletions flow/connectors/utils/structured/malformed_data.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
package structured

import (
"encoding/json"
"fmt"
"math"

"github.com/PeerDB-io/peerdb/flow/generated/protos"
"github.com/PeerDB-io/peerdb/flow/shared/types"
)

const MalformedDataColumn = "malformed_data"
Comment thread
pfcoperez marked this conversation as resolved.
Outdated

type MalformedReason int

const (
ReasonUnexpected MalformedReason = iota
ReasonTypeMismatch
ReasonNaN
ReasonDuplicatedFields
)

func (r MalformedReason) String() string {
switch r {
case ReasonUnexpected:
return "unexpected"
Comment thread
pfcoperez marked this conversation as resolved.
Outdated
case ReasonTypeMismatch:
return "type_mismatch"
case ReasonNaN:
return "not_a_number"
case ReasonDuplicatedFields:
return "duplicated_fields"
default:
return ""
}
}

// This type represents malformed data state.
// As structured is applied to a document based on a ClickHouse schema, problematic fields
// get recorded in this state.
type MalformedData struct {
// Map from seen problematic fields to their respective reasons.
reasons map[string]MalformedReason
// Optionally, the problematic values
values map[string]types.QValue
}

// NewMalformedData creates a new instance of MalformedData.
func NewMalformedData() *MalformedData {
return &MalformedData{
reasons: make(map[string]MalformedReason),
values: make(map[string]types.QValue),
}
}

// AddField adds a problematic field along with its reason and, when value is not nil, its value to the
// MalformedData.
func (m *MalformedData) AddField(field string, reason MalformedReason, value types.QValue) {
m.reasons[field] = reason
if value != nil {
m.values[field] = value
}
}

func (m *MalformedData) IsEmpty() bool {
return len(m.reasons) == 0
}

// MarshalJSON implements json.Marshaler, serializing the malformed fields state in the shape of a JSON object
// which is query friendly if ingested into ClickHouse:
//
// {"<field>": {"<reason>": true, "value": <value>}}
func (m *MalformedData) MarshalJSON() ([]byte, error) {
fields := make(map[string]map[string]any, len(m.reasons))
for name, reason := range m.reasons {
reasonName := reason.String()
if reasonName == "" {
return nil, fmt.Errorf("malformed field %q has unknown reason %d", name, int(reason))
}
fields[name] = map[string]any{reasonName: true}
}
for name, value := range m.values {
field, ok := fields[name]
if !ok {
return nil, fmt.Errorf("malformed field %q has a value but no reason", name)
}
// encoded from the Go value: nulls become JSON null, QValueJSON stays a (quoted) string
var jsonValue any
if value != nil {
jsonValue = value.Value()
}
if isJSONRepresentable(jsonValue) {
marshaledValue, err := json.Marshal(jsonValue)
if err != nil {
field["value"] = fmt.Sprintf("%v", jsonValue)
} else {
field["value"] = json.RawMessage(marshaledValue)
}
} else {
fields[name] = map[string]any{ReasonNaN.String(): true}

@jgao54 jgao54 Sep 11, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

i think claude already mentioned this but here it's overriding the default reason and iiuc is only set because json.Marshal does not support it. could instead convert the NaN/-Inf/+Inf value as string and then the original reason can be used and reasonNaN can be removed (since ClickHouse supports NaN/Inf/-Inf for floats)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Yes Claude flagged it too. I disregarded its comment for this reason: In these cases, the data could be malformed for two different reasons: The out of domain value and the potentially being an unexpected or wrong type column.

I want to keep the malformed column simple and avoid the situation were we record or need to detect all the possible ways in which a value is broken. So I decided that it was better to flag the completely out of domain value because:

  • Unexpected type is a super-category of this problem. Bad domain value implies bad value type -> Unexpected type.
  • Unexpected field is less disruptive than broken data.

@jgao54 jgao54 Sep 11, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

maybe I am misunderstanding something here so want to clarify.

my understanding is that ReasonNaN here does not mean that the data itself cannot be replicated due to NaN (because clickhouse supports it for floats), but it's set to NaN because _peerdb_malformed_data column is JSON type and that does not support NaN if the data is deemed incompatible for some other reason (e.g. unexpected field, or type mismatch, but it shouldn't be ambiguous here since unexpected field means the field is not expected from the schema, or type mismatch which means the actual type does not match the inferred type). if my understanding is correct, then i think this code here is hiding that root cause, and overriding with NaN instead.

I can't think of a valid scenario where NaN is a valid reason on during inferencing itself: if it's a float, clickhouse can handle it; if it's not a float, then it's a typeMismatch error of some sort.

Does this match your understanding or did I miss something here

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

@jgao54 You understood it perfectly!

There is a wrong premise in this comment of mine:

Unexpected type is a super-category of this problem. Bad domain value implies bad value type -> Unexpected type.

I assumed we didn't accept these float values for ingestion but you clarified that they do, thank you!

Corrected in #4798

}
}
return json.Marshal(fields)
}

func (m *MalformedData) AsQValue() (types.QValue, error) {
jsonData, err := m.MarshalJSON()
if err != nil {
return nil, err
}
return types.QValueJSON{Val: string(jsonData)}, nil
}

// Describes the column malformed data is recorded in.
func MalformedDataFieldDescription() *protos.FieldDescription {
return &protos.FieldDescription{
Name: MalformedDataColumn,
Type: string(types.QValueKindJSON),
TypeModifier: -1,
Nullable: true,
}
}

// isJSONRepresentable reports whether v can be encoded by encoding/json, which rejects NaN and ±Inf floats.
func isJSONRepresentable(v any) bool {
validateFloat := func(f float64) bool {
return !math.IsNaN(f) && !math.IsInf(f, 0)
}
switch f := v.(type) {
case float64:
return validateFloat(f)
case float32:
return validateFloat(float64(f))
default:
return true
}
}
131 changes: 131 additions & 0 deletions flow/connectors/utils/structured/malformed_data_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
package structured

import (
"encoding/json"
"math"
"testing"

"github.com/stretchr/testify/require"

"github.com/PeerDB-io/peerdb/flow/shared/types"
)

func TestMalformedDataMarshalJSON(t *testing.T) {
tests := []struct {
setup func(m *MalformedData)
desc string
expected string
}{
{
desc: "empty",
setup: func(*MalformedData) {},
expected: `{}`,
},
{
desc: "reason without value",
setup: func(m *MalformedData) { m.AddField("a", ReasonUnexpected, nil) },
expected: `{"a":{"unexpected":true}}`,
},
{
desc: "not a number is a reason without value",
setup: func(m *MalformedData) { m.AddField("a", ReasonNaN, nil) },
expected: `{"a":{"not_a_number":true}}`,
},
{
desc: "string value",
setup: func(m *MalformedData) { m.AddField("a", ReasonTypeMismatch, types.QValueString{Val: "x"}) },
expected: `{"a":{"type_mismatch":true,"value":"x"}}`,
},
{
desc: "duplicated field with value",
setup: func(m *MalformedData) { m.AddField("a", ReasonDuplicatedFields, types.QValueString{Val: "x"}) },
expected: `{"a":{"duplicated_fields":true,"value":"x"}}`,
},
{
desc: "JSON value is kept as a quoted string",
setup: func(m *MalformedData) { m.AddField("a", ReasonTypeMismatch, types.QValueJSON{Val: `{"k":1}`}) },
expected: `{"a":{"type_mismatch":true,"value":"{\"k\":1}"}}`,
},
{
desc: "null value",
setup: func(m *MalformedData) {
m.AddField("a", ReasonTypeMismatch, types.QValueNull(types.QValueKindInt64))
},
expected: `{"a":{"type_mismatch":true,"value":null}}`,
},
{
desc: "scalar values are encoded natively",
setup: func(m *MalformedData) {
m.AddField("i", ReasonTypeMismatch, types.QValueInt64{Val: 1})
m.AddField("b", ReasonTypeMismatch, types.QValueBoolean{Val: true})
m.AddField("f", ReasonTypeMismatch, types.QValueFloat64{Val: 1.5})
},
expected: `{"b":{"type_mismatch":true,"value":true},"f":{"type_mismatch":true,"value":1.5},"i":{"type_mismatch":true,"value":1}}`,
},
{
desc: "value is overwritten by a later AddField for the same field",
setup: func(m *MalformedData) {
m.AddField("a", ReasonUnexpected, types.QValueString{Val: "x"})
m.AddField("a", ReasonTypeMismatch, types.QValueString{Val: "y"})
},
expected: `{"a":{"type_mismatch":true,"value":"y"}}`,
},
{
desc: "non-finite float values are dropped and the reason replaced by not_a_number",
setup: func(m *MalformedData) {
m.AddField("nan", ReasonTypeMismatch, types.QValueFloat64{Val: math.NaN()})
m.AddField("inf", ReasonUnexpected, types.QValueFloat64{Val: math.Inf(1)})
m.AddField("neginf", ReasonTypeMismatch, types.QValueFloat64{Val: math.Inf(-1)})
m.AddField("nan32", ReasonTypeMismatch, types.QValueFloat32{Val: float32(math.NaN())})
m.AddField("finite", ReasonTypeMismatch, types.QValueFloat64{Val: 1.5})
},
expected: `{` +
`"finite":{"type_mismatch":true,"value":1.5},` +
`"inf":{"not_a_number":true},` +
`"nan":{"not_a_number":true},` +
`"nan32":{"not_a_number":true},` +
`"neginf":{"not_a_number":true}}`,
},
{
desc: "compound values holding non-finite floats fall back to their string representation",
setup: func(m *MalformedData) {
m.AddField("arr64", ReasonTypeMismatch, types.QValueArrayFloat64{Val: []float64{1, math.NaN()}})
m.AddField("arr32", ReasonUnexpected, types.QValueArrayFloat32{Val: []float32{float32(math.Inf(1))}})
m.AddField("finite", ReasonTypeMismatch, types.QValueArrayFloat64{Val: []float64{1, 2}})
},
expected: `{` +
`"arr32":{"unexpected":true,"value":"[+Inf]"},` +
`"arr64":{"type_mismatch":true,"value":"[1 NaN]"},` +
`"finite":{"type_mismatch":true,"value":[1,2]}}`,
},
}

for _, test := range tests {
t.Run(test.desc, func(t *testing.T) {
m := NewMalformedData()
test.setup(m)
// marshal through encoding/json to also prove the Marshaler is picked up via the pointer
b, err := json.Marshal(m)
require.NoError(t, err)
require.JSONEq(t, test.expected, string(b))
require.Equal(t, test.expected, string(b), "output must be deterministic")
})
}

t.Run("unknown reason is an error", func(t *testing.T) {
m := NewMalformedData()
m.AddField("a", MalformedReason(99), nil)
_, err := json.Marshal(m)
require.ErrorContains(t, err, `"a"`)
require.ErrorContains(t, err, "unknown reason 99")
})

t.Run("value without reason is an error", func(t *testing.T) {
m := NewMalformedData()
m.AddField("a", ReasonTypeMismatch, nil)
m.values["b"] = types.QValueString{Val: "x"} // AddField cannot produce this state
_, err := json.Marshal(m)
require.ErrorContains(t, err, `"b"`)
require.ErrorContains(t, err, "no reason")
})
}
Loading
Loading