feat(structured-ingestion) DBI-1096: Generic flattening schema projection for structured ingestion - #4781
feat(structured-ingestion) DBI-1096: Generic flattening schema projection for structured ingestion #4781pfcoperez wants to merge 5 commits into
Conversation
…on so it can be re-used
…ort to be ingested along with correct values
… a JSON or a BSON object) into a flat array of column QValues including malformed data according to the schema
…data". The column name suffices.
Code reviewNo issues found. Checked for bugs and CLAUDE.md compliance. |
|
@fable do a deep review with explanations of the changes. |
|
Claude finished @pfcoperez's task in 4m 6s —— View job Deep review: generic flattening schema projection for structured ingestion
What the PR doesThe PR introduces a source-agnostic pipeline for ingesting dynamic-schema documents into a fixed columnar schema, in three layers:
Nothing outside the tests calls Findings1. Behavior change hidden in the "pure refactor": 2. Non-finite floats nested inside compound values fail the whole record (medium) — 3. 4. Duplicate record fields produce contradictory output (low) — BSON legally allows repeated keys and JSON tolerates them, and 5. Drift risk in the string mapping (nit) — 6. Pre-existing quirks now shared (nits, no action needed) — the 7. NaN downgrade discards the original reason (design nit) — What's good
None of the findings are blockers for merging infrastructure-only code; 2 and 3 are the ones I'd resolve (or explicitly rule intentional) before the MongoDB walker starts feeding real data through this path. |
|
|
||
| // QValueKindForType maps a ClickHouse column type to the QValueKind expected for its values, | ||
| // returned as the string form of the flow module's types.QValueKind. | ||
| func QValueKindForType(columnType string) (string, error) { |
There was a problem hiding this comment.
hm
why can't we return QValueKind type from here?
because you want it to stay in pkg and pkg can't refer to flow/shared/types package?
There was a problem hiding this comment.
Because flow/pgk doesn't have that type defined. I think that's OK since it's just a rename of string (from now) but it is less than ideal.
Once in the past I attempted bringing Q abstractions to flow/pkg but that's a rabbit hole for a PR like this one.
There was a problem hiding this comment.
yeah, but I'm wondering why do you want to put it in pkg at all?
afaiu we put things in pkg when we want to re-use them from the other repos.
this function refers to internal peerdb qvalue/qkind concept which is unknown to the outside world
i guess you didn't want to put it in flow/connectors/clickhouse/clickhouse.go because you need to reference it from utils/structured?
i'd rather keep it closer to QValueKind itself so that it would be hard to forget to update it when a new kind is added
There was a problem hiding this comment.
I am preparing to potentially use them in Discovery validations.
| case float64: | ||
| return !math.IsNaN(f) && !math.IsInf(f, 0) | ||
| case float32: | ||
| return !math.IsNaN(float64(f)) && !math.IsInf(float64(f), 0) |
There was a problem hiding this comment.
nit: can wrap float validation into helper:
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
}
}…or destination_type overrides in normalize (#4783) With a table mapping column setting both a `destination_type` override and nullability (table- or column-level `nullable_enabled`), the DDL generator creates the destination column as Nullable(<type>), but the normalize query still extracted it as plain <type>. JSONExtract to a non-nullable type turns JSON nulls into the type's default, so NULL values silently landed as `0`, `" "`, etc. instead of NULL. This PR makes the normalize query generator mirror the DDL: - Wraps the override in Nullable(...) under the same conditions. - Guards both generators against double wrapping when the override is already spelled Nullable(...), which previously produced invalid Nullable(Nullable(<type>)) DDL. Part of: https://linear.app/clickhouse/issue/DBI-1096 Related to: - #4781 - #4774
|
|
||
| // NewSchemaProjector resolves the schema columns' kinds through schemaToQKind, failing on a type it does | ||
| // not know, a column declared twice or one named as the malformed data column. | ||
| func NewSchemaProjector( |
There was a problem hiding this comment.
i wouldn't add 3 NewProjectorFromX implementations prematurely
imo just one NewSchemaProjector is fine until we see the need for other ways to create this object
| } | ||
|
|
||
| // Columns are the structured schema (order is relevant). | ||
| func (sc *SchemaProjector) Columns() []types.QField { |
There was a problem hiding this comment.
i'm not sure i understand why we'd need these two methods - Columns and QRecordSchema.
can we maybe add them once we see the need for that in the code that is going to use this SchemaProjector?
kinda the same concern as 3 constructors.
There was a problem hiding this comment.
This was used in the context of #4774 at a certain point but it's no longer. We can remove it indeed.
This PR adds the generic tools to implement structured ingestion (using QValue system) for any unstructured (dynamic schema) data source.
It provided two abstractions for this purpose:
SchemaProjector(db9ce41): Upon initialization, it receives the target schema ([]*protos.ColumnSetting) and a function to interpret these raw mappings column settings as an ordered array ofQValue. ThroughProjectRecordmethod it transforms generic dynamic schema records (abstracted behind a walk iterator so it could be JSON, BSON, or anything that a lazy walker function can take) into an ordered array ofQValueinstances matching the schema plus an extraQValueJSONcolumn (malformed_data) reporting records not matching the schema (see next point). In the ouput QValues record all fields are nullable as missing fields are consideredNULLfor structured logging.One example of lazy iterator applied for MongDB document flattening using
SchemaProjectoris:peerdb/flow/connectors/mongo/qvalue_convert.go
Lines 246 to 273 in 859e699
MalformedData(898e5d0): While processing each individual unstructured document, it is the tracker of schema violations. Its JSON marshalling method implementation generates a JSON structure that makes it possible and easy to query data that failed to fit into the schema at destination CH table:e.g:
{ "year": { "unexpected": true, "value": 2019 } }{ "year": { "type_mismatch": true, "value": "two thousand nineteen" } }Query example at destination CH table:
Given that it provides a default CH types to QKind schema interpreter (Used for MongDB structured ingestion) and that it matches the equivalence function already present in the ClickHouse target connector implementation, this PR also factors out the conversion table so it's shared: 60a80b9
Part of: https://linear.app/clickhouse/issue/DBI-1096