[FEATURE](codegen) Add a stac-table-columns codegen format - #724
Seth Fitzsimmons (sethfitz) wants to merge 3 commits into
Conversation
🗺️ Schema reference docs preview is live!
Note ♻️ This preview updates automatically with each push to this PR. |
Francisco Jiménez (jjcfrancisco)
left a comment
There was a problem hiding this comment.
Looks good in general. I left few comments to add functionality due to new v1.3.0
|
|
||
|
|
||
| @dataclass | ||
| class Column: |
There was a problem hiding this comment.
With v1.3.0, we should add a data_type field here alongside type, plus a small mapping (spec vocab). Although, a lot of columns will resolve to "other" for Overture, only numerics like level carry real signal.
We should also add geometry_type (new in v1.3.0) for the geometry column. Overture already knows whether each feature type is Point / LineString / Polygon.
And unit from common metadata (now permitted on Column Object in v1.3.0). Overture carries real units like km/h or mph for speed limits
There was a problem hiding this comment.
data_type needed no mapping table: STAC common metadata's vocabulary (int8…int64, uint8…uint64, float32/float64, other) already matches Overture's own numeric base-type names, so it's an allowlist against that vocabulary rather than a translation.
One distinction: our earlier reading that there was no concrete vocabulary was about type, which is still unconstrained (stac-extensions/table#1 is open, lists three options, picks none). v1.3.0 draws the line explicitly: type takes the native name in the file format, data_type the standardized one from STAC common metadata. The vocabulary is stac-spec's and predates this; what's new is permission to use it on a Column Object, which v1.2.0 didn't mention at all.
type and data_type now disagree on floats. data_type says float32/float64 because that vocabulary is fixed; type says float/double because those are Arrow's names. There's a test pinning the mismatch so nobody later "fixes" one into the other.
On geometry: this emits vector:geometry_types, read from the model's GeometryTypeConstraint, since that's what's declared. The released v1.3.0 defines no geometry_type on the Column Object — it points at the Vector extension instead.
Overture Schema doesn't define units in a way that's compatible with what STAC is looking for, so unit isn't implemented. I have some ideas for a consistent unit definition that could then be surfaced in both the Markdown and STAC renderers:
class Unit(str, DocumentedEnum): ...
class LengthUnit(Unit): ...
Height = NewType("Height", Annotated[float64, LengthUnit.M])
Width = NewType("Width", Annotated[float64, LengthUnit.M])
height: Height | None = Field(default=None, description="Height of the building or part.")The type names the quantity and the annotation carries the unit, so a height and a width stay distinct even though both are metres — mixing them is a type error rather than a silent bug. The wire format stays a plain double and Pydantic validation is unchanged, while the unit becomes a machine-readable fact a renderer can emit.
|
|
||
| # Lowercase DuckDB SQL, the dialect the Portolan spec repo's own generated | ||
| # reference catalog emits. See the module docstring: this is a choice. | ||
| _DUCKDB_TYPES: dict[str, str] = { |
There was a problem hiding this comment.
Despite DuckDB being the only executable example in the STAC repo, I'd argue for Arrow.
Prior art in the wild. I (randomly) picked three published Portolan catalogs:
All three have Arrow flavored primitives (int64, string, double, binary) although slightly diverge on struct grammar. It seems a little bit wild at the moment.
Even the STAC's own vocab is already Arrow(ish), the data_type list in STAC common metadata
There was a problem hiding this comment.
Agreed, and switched — type is Arrow now.
STAC's data_type vocabulary is already Arrow-shaped, so under DuckDB type a column carried two forms of the same physical type (type: "integer", data_type: "int32") that shared no vocabulary at all. Under Arrow they converge for every primitive, diverging only where Arrow's naming is finer than STAC's (string/binary against STAC's blanket other).
Both the primitive names and the composite grammar come from pyarrow's own stringifier rather than from memory, cross-checked against the Portolan catalogs you cited and against the Arrow schema of an actual 2026-08-19.0 release file read from S3. That third source also settled Geometry → binary: it is literally what Arrow reports for Overture's own geometry column. The semantic loss is logged as a gap, and vector:geometry_types carries the part the schema can assert.
One thing the switch surfaced without changing: the bbox struct is emitted in the BBox class's declaration order, xmin, ymin, xmax, ymax, while released Overture files order it xmin, xmax, ymin, ymax. A member-order constant matching one publisher's file layout would be a fact about that publisher rather than about the schema, so this renderer keeps the model's order and the discrepancy is accepted deliberately. It is recorded in the constant's comment and in a test, so it reads as a decision rather than an oversight.
One place the sources disagree. pyarrow's constructor stringifies list elements as list<item: …>, while Arrow reading Overture's own Parquet reports list<element: …>. This emits item, because the same round-trip that renames it also renders maps as map<string, string ('common')> — a file artifact rather than a type name — so following "what the reader reports" consistently produces something worse. Happy to flip it if you read the precedent differently; it is one line.
Separately, datetime maps to timestamp[us, tz=UTC], which describes the model rather than the release: no shipped Overture column is actually an Arrow timestamp (sources[].update_time is a string). That is right for a schema-derived renderer, but the "what we distribute" justification does not reach that field, so it is worth a second opinion.
FieldSpec had no slot for either, so a renderer that needed to know a field's default -- or that it was deprecated -- had to re-walk Pydantic and re-derive the unwrapping the extraction layer already does. Two sentinels mean "no default", not one: PydanticUndefined, and the MISSING that Omitable[T] installs to get JSON Schema omissibility rather than nullability. Extracting MISSING as a value would make every Omitable field claim a default it does not have. NO_DEFAULT keeps the absence distinct from a declared default of None, which is legal and different. default_factory deliberately does not land here. A factory is behavior, and calling it at extraction time would freeze one sample of a value whose whole point is to be produced per instance. Signed-off-by: Seth Fitzsimmons <seth@mojodna.net>
A new --format stac-table-columns emitting one STAC properties fragment per feature type. Closes the gap in stac.overturemaps.org, whose table:columns today carries names and neither type nor description. The column list is the easy half. A Column Object is name/type/ description and nothing else, so the renderer's second output is a gap log: 3058 entries over 246 columns -- 1528 constraints, 628 statements of optionality, 395 defaults, 353 nested field descriptions, 103 enum sites over 41 vocabularies. None of them are IR gaps. The models can supply everything a Column Object asks for, so this is a strict, lossy projection with no authoring step. Three decisions are stated in the code, because nothing downstream can falsify any of them. The type dialect is lowercase DuckDB. The extension declares type as a bare JSON Schema string, so there is no vocabulary and no validator -- an absurd type string passes. Published catalogs carry two dialects in mixed case across 526 columns, and the spec repo's own generated reference catalog emits DuckDB. A discriminated union is taken from the merged field list, and every arm-only loss is logged. Walking only the arms misses fields that stay un-narrowed in the merged list; walking only the merged list drops each non-first arm at a duplicated name. Both losses are real and neither can raise, because the arms stringify identically -- three names over seven sites on the current models, all in segment. Ambiguity is refused rather than guessed. table:primary_geometry is emitted only when exactly one geometry column exists; table:row_count never, since a row count is a property of data and this path has only a schema. A recursive model raises, because a type string cannot name a type and so a cycle cannot terminate. Fixes #723. Signed-off-by: Seth Fitzsimmons <seth@mojodna.net>
jjcfrancisco's review on #724 asked for four things against the extension's new v1.3.0, released 2026-09-07 after this PR was written against v1.2.0: - Bump the extension URL. Done. - Add data_type (STAC common metadata) alongside type. Done: an allowlist against Overture's own numeric base-type names, which already spell the vocabulary verbatim; everything else -- structs, arrays, enums, unions, strings -- gets "other", itself a vocabulary member, not an omission. - Add geometry_type for the geometry column. The released v1.3.0 README defines no such field on the Column Object -- checked at https://github.com/stac-extensions/table/blob/v1.3.0/README.md. What it actually recommends is vector:geometry_types (plural) from the separate Vector extension, a list of GeoJSON type names. Implemented that instead, reading GeometryTypeConstraint.allowed_types off the geometry field: an exhaustive schema bound validated on every row, not a guess about what a dataset happens to contain. stac_extensions now also declares the Vector extension whenever a column emits the field, or the reference would not validate against either schema. A geometry column with no such constraint gets neither the field nor a claim about it, logged as a gap instead -- none of the shipped models hit that branch. - Add unit for columns with a real unit, e.g. speed limits. Not implemented: no Overture field carries a structured, column-wide fixed unit anywhere in the IR. Speed limits carry theirs as a per-row SpeedUnit enum nested inside Speed, not a schema-fixed unit, so a unit on the speed_limits column would misdescribe data that can legitimately mix mph and km/h row to row. The fields that really do have one fixed unit (Height, Elevation, Depth, roof bearing) carry it only in a free-text description ("... in meters"), which this renderer will not parse to fabricate a structured fact the schema never asserted. Separately, `type` now carries Arrow type names rather than lowercase DuckDB SQL. Arrow is what Overture actually distributes -- the release is GeoParquet -- so the names a reader of the published data reports are the ones a column list describing it should carry. Every spelling and the composite grammar came from pyarrow's own stringifier, cross-checked against the two published catalogs raised in review, which spell their columns int64, string, double, binary and struct<xmin: double, ...>, and against the Arrow schema of a live release file. - Scalars: double and float rather than float64/float32 (Arrow's own names), string not varchar, bool not boolean, binary not blob, timestamp[us, tz=UTC] not "timestamp with time zone", date32[day] not date. The sized integers need no translation -- Overture's base-type names already are Arrow's, the same way they already are STAC's. - Composites: struct<name: type, ...>, list<item: type>, map<key, value>. A round-trip through Parquet renames a list's child "element" and appends the Parquet group name to a map's stringification; both are artifacts of the file rather than type names, so what is emitted is pyarrow's canonical constructor spelling. - Geometry is binary. Arrow has no geometry type: GeoParquet stores WKB in a binary column and keeps the semantics in file-level metadata, and binary is exactly what an Arrow reader reports for Overture's own released geometry column, as it is in both cited catalogs. The erasure is logged as a gap rather than absorbed; vector:geometry_types still carries the part of the semantic the schema can assert. type and data_type therefore disagree on floats -- Arrow says float/double where STAC common metadata says float32/float64. That is correct rather than a bug: each field is bound to its own vocabulary and harmonising them would make one of them lie. The module docstring and a test both say so, so the next reader does not "fix" it. Signed-off-by: Seth Fitzsimmons <seth@mojodna.net>
53d1757 to
0d46a0d
Compare
0d46a0d to
9e92f0b
Compare
Tracks the table extension's v1.3.0, released 2026-09-07 after this PR was written against v1.2.0. Four changes: - Bump the extension URL. Done. - Add data_type (STAC common metadata) alongside type. Done: an allowlist against Overture's own numeric base-type names, which already match the vocabulary verbatim; everything else -- structs, arrays, enums, unions, strings -- gets "other", itself a vocabulary member, not an omission. - Add geometry_type for the geometry column. The released v1.3.0 README defines no such field on the Column Object -- checked at https://github.com/stac-extensions/table/blob/v1.3.0/README.md. What it actually recommends is vector:geometry_types (plural) from the separate Vector extension, a list of GeoJSON type names. Implemented that instead, reading GeometryTypeConstraint.allowed_types off the geometry field: an exhaustive schema bound validated on every row, not a guess about what a dataset happens to contain. stac_extensions now also declares the Vector extension whenever a column emits the field, or the reference would not validate against either schema. A geometry column with no such constraint gets neither the field nor a claim about it, logged as a gap instead -- none of the shipped models hit that branch. - Add unit for columns with a real unit, e.g. speed limits. Not implemented: no Overture field carries a structured, column-wide fixed unit anywhere in the IR. Speed limits carry theirs as a per-row SpeedUnit enum nested inside Speed, not a schema-fixed unit, so a unit on the speed_limits column would misdescribe data that can legitimately mix mph and km/h row to row. The fields that really do have one fixed unit (Height, Elevation, Depth, roof bearing) carry it only in a free-text description ("... in meters"), which this renderer will not parse to fabricate a structured fact the schema never asserted. Separately, `type` now carries Arrow type names rather than lowercase DuckDB SQL. Arrow is what Overture actually distributes -- the release is GeoParquet -- so the names a reader of the published data reports are the ones a column list describing it should carry. Every type name and the composite grammar came from pyarrow's own stringifier, and match published catalogs, which name their columns int64, string, double, binary and struct<xmin: double, ...>. - Scalars: double and float rather than float64/float32 (Arrow's own names), string not varchar, bool not boolean, binary not blob, timestamp[us, tz=UTC] not "timestamp with time zone", date32[day] not date. The sized integers need no translation -- Overture's base-type names already are Arrow's, the same way they already are STAC's. - Composites: struct<name: type, ...>, list<item: type>, map<key, value>. A round-trip through Parquet renames a list's child "element" and appends the Parquet group name to a map's stringification; both are artifacts of the file rather than type names, so what is emitted is pyarrow's canonical constructor form. - Geometry is binary. Arrow has no geometry type: GeoParquet stores WKB in a binary column and keeps the semantics in file-level metadata, and binary is exactly what an Arrow reader reports for Overture's own released geometry column, as it is in both cited catalogs. The erasure is logged as a gap rather than absorbed; vector:geometry_types still carries the part of the semantic the schema can assert. type and data_type therefore disagree on floats -- Arrow says float/double where STAC common metadata says float32/float64. That is correct rather than a bug: each field is bound to its own vocabulary and harmonising them would make one of them lie. The module docstring and a test both say so, so the next reader does not "fix" it. Signed-off-by: Seth Fitzsimmons <seth@mojodna.net>
9e92f0b to
9e860f2
Compare
Description
Adds a
stac-table-columnscodegen format that renders the STAC Table extension'stable:columnsfrom the Pydantic models, one STAC properties fragment per feature type — 15 in all. It targets table extension v1.3.0, released 2026-09-07.The models already hold everything a Column Object asks for, so the catalog is a projection with no authoring step. v1.3.0's Column Object is
name,typeanddescription, plus what it newly admits from STAC common metadata (data_type,unit) and from the Vector extension (vector:geometry_types). Everything else the IR carries — constraints, optionality, defaults, nested field descriptions, enum vocabularies — still has nowhere to go, so the renderer's second output is a gap log that records each of those as it is dropped.Four decisions are explicit in the code, because nothing downstream can check them.
Column types use Arrow's names —
int64,string,double,binary,struct<a: int32, b: string>,list<item: int32>.typeis an unconstrained string, and real catalogs carry whatever their generating engine's stringifier produced; stac-extensions/table#1, "Firm up the definition ofColumnObject.type", has been open since 2021, lists three options and picks none. Arrow is what Overture distributes — the release is GeoParquet, and these are the names an Arrow reader reports on opening it — and it matches published catalogs, which name columnsint64,string,double,binary. The vocabulary and the composite grammar both come frompyarrow's own stringifier rather than from memory.Struct member order is the model's declaration order. For
bboxthat does not match what Overture ships — released files order itxmin, xmax, ymin, ymax, as does this repo's PySparkBBOX_STRUCT— and the discrepancy is accepted rather than encoded: a constant matching one publisher's file layout would state a fact about that publisher rather than about the schema. It is recorded in the constant's comment and pinned by a test, so it reads as a decision rather than an oversight.typeanddata_typeconsequently disagree on floats, and that is deliberate rather than an oversight.data_typeis bound to STAC common metadata's fixed vocabulary, which names themfloat32/float64;typeis bound to Arrow, which names the same twofloat/double. Harmonising them would make one field lie about the vocabulary it claims to speak. A test pins the mismatch so a later reader finds it recorded rather than "fixes" it.data_typeis Overture's own base-type name, not a translation. STAC common metadata's data-type vocabulary (int8…int64,uint8…uint64,float32/float64,other) already matches Overture's numeric base-type names verbatim, so this is an allowlist against that vocabulary rather than a lookup table. Every column gets one —otheris a member of the vocabulary, not an omission — so most Overture columns resolve toother, and only the numerics carry signal.Flattening a discriminated union drops information, and every drop is logged. In
segment,RoadClassandRailClasseach contribute aclasscolumn; a flat column list has one slot for it, so one description is kept and the other disappears from the output. This happens seven times insegment. Nothing catches it on its own: the alternatives have identical physical types, so every compatibility check passes and the output looks complete. The renderer emits aflatten-collisiongap at each site, the only record that something was dropped.Ambiguity is refused, and
vector:geometry_typesis the one case that looks like an exception and is not.table:primary_geometryis emitted when exactly one geometry column exists and omitted, with the omission logged, when two do — nothing in the Pydantic model indicates which is primary.table:row_countis never emitted: a row count is a property of data, and this path has only a schema. A recursive model raises, because a type string cannot name a type and so a cycle cannot terminate. Union alternatives resolving to different physical types raise.vector:geometry_typesis defined as the geometry types present in the dataset, which by that same rule is a data fact this renderer cannot measure. It is emitted anyway, because it is read fromGeometryTypeConstraint, which is validated on every row and is therefore an exhaustive bound on what a column can contain rather than a guess about what it does. A geometry column with no such constraint gets neither the field nor a claim about it — logged as a gap instead. Because the field belongs to another extension, the fragment declares the Vector extension instac_extensionsexactly when a column emits it, or it would not validate.unitis not implemented, because Overture Schema has no consistent way to encode one. Where a unit varies per row it is a field —Speedcarries aSpeedUnit, so a singleunitstring on that column would assert every row shares a unit when the schema allows mixingmphandkm/h. Where a unit is fixed (Height,Elevation,Depth, roof bearing) it appears only in a free-text description. Neither is a column-level fact, and this renderer does not parse prose.The first commit stands on its own: extraction now carries
defaultanddeprecatedthrough toFieldSpec, which addresses #674.Closes #723.
Reference
vector:geometry_typeson geometry columnsvector:geometry_typescomes fromColumnObject.typestac-extensions/table#1 — the open thread on whattypemeansdeprecatedcarry the first commit addsChecklist