Repository: planetlabs/gpq
Pull request #39 (2023-06-22 17:46:42)

Additional information in describe output
This adds additional information to the describe output:
 * geometry column crs (name / id)
 * number of rows
 * geoparquet version

The edges, orientation, and crs per geometry column is summarized in a "detail" column in the text output.  Number of rows and version are shown in the table footer.  The primary geometry column name is shown in bold.
```shell
# gpq describe internal/testdata/cases/example-v1.0.0-beta.1.parquet
╭────────────┬──────────────┬────────────┬────────────┬──────────┬───────────────────────┬───────────────────────────┬──────────────────────────╮
│ COLUMN     │ TYPE         │ ANNOTATION │ REPETITION │ ENCODING │ GEOMETRY TYPES        │ BOUNDS                    │ DETAIL                   │
├────────────┼──────────────┼────────────┼────────────┼──────────┼───────────────────────┼───────────────────────────┼──────────────────────────┤
│ pop_est    │ int64        │            │ 0..1       │          │                       │                           │                          │
│ continent  │ binary       │ STRING     │ 0..1       │          │                       │                           │                          │
│ name       │ binary       │ STRING     │ 0..1       │          │                       │                           │                          │
│ iso_a3     │ binary       │ STRING     │ 0..1       │          │                       │                           │                          │
│ gdp_md_est │ double       │            │ 0..1       │          │                       │                           │                          │
│ geometry   │ binary       │            │ 0..1       │ WKB      │ Polygon, MultiPolygon │ [-180, -90, 180, 83.6451] │  edges │ planar          │
│            │              │            │            │          │                       │                           │  crs   │ WGS 84 (CRS84)  │
├────────────┼──────────────┼────────────┴────────────┴──────────┴───────────────────────┴───────────────────────────┴──────────────────────────┤
│ ROWS       │ 5            │                                                                                                                   │
│ VERSION    │ 1.0.0-BETA.1 │                                                                                                                   │
╰────────────┴──────────────┴───────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
```




Patch:
diff --git a/cmd/gpq/describe.go b/cmd/gpq/describe.go
index 51f1f2b..a0595ac 100644
--- a/cmd/gpq/describe.go
+++ b/cmd/gpq/describe.go
@@ -23,8 +23,10 @@ import (
 	"strings"
 
 	"github.com/jedib0t/go-pretty/v6/table"
+	"github.com/jedib0t/go-pretty/v6/text"
 	"github.com/planetlabs/gpq/internal/geoparquet"
 	"github.com/segmentio/parquet-go"
+	"golang.org/x/term"
 )
 
 type DescribeCmd struct {
@@ -33,6 +35,17 @@ type DescribeCmd struct {
 	Unpretty bool   `help:"No newlines or indentation in the JSON output."`
 }
 
+const (
+	ColName          = "Column"
+	ColType          = "Type"
+	ColAnnotation    = "Annotation"
+	ColRepetition    = "Repetition"
+	ColEncoding      = "Encoding"
+	ColGeometryTypes = "Geometry Types"
+	ColBounds        = "Bounds"
+	ColDetail        = "Detail"
+)
+
 func (c *DescribeCmd) Run() error {
 	input, readErr := os.Open(c.Input)
 	if readErr != nil {
@@ -74,52 +87,102 @@ func (c *DescribeCmd) Run() error {
 func (c *DescribeCmd) formatText(info *Info) error {
 	metadata := info.Metadata
 
-	header := table.Row{"Column", "Type", "Annotation", "Optional", "Repeated"}
+	header := table.Row{ColName, ColType, ColAnnotation, ColRepetition}
+	columnConfigs := []table.ColumnConfig{}
 	if metadata != nil {
-		header = append(header, "Encoding", "Geometry Types", "Orientation", "Edges", "Bounds")
+		header = append(header, ColEncoding, ColGeometryTypes, ColBounds, ColDetail)
+		columnConfigs = append(columnConfigs, table.ColumnConfig{
+			Name:             ColGeometryTypes,
+			WidthMax:         50,
+			WidthMaxEnforcer: text.WrapSoft,
+		}, table.ColumnConfig{
+			Name:             ColBounds,
+			WidthMax:         50,
+			WidthMaxEnforcer: text.WrapSoft,
+		})
 	}
 
+	out := os.Stdout
 	tbl := table.NewWriter()
-	tbl.SetOutputMirror(os.Stdout)
+	if term.IsTerminal(int(out.Fd())) {
+		width, _, err := term.GetSize(int(out.Fd()))
+		if err == nil {
+			tbl.SetAllowedRowLength(width)
+		}
+	}
+
+	tbl.SetColumnConfigs(columnConfigs)
 	tbl.AppendHeader(header)
 
 	for _, field := range info.Schema.Fields {
 		name := field.Name
 		if metadata != nil && metadata.PrimaryColumn == name {
-			name += " *"
+			name = text.Bold.Sprint(name)
 		}
-		row := table.Row{name, field.Type, field.Annotation, field.Optional, field.Repeated}
+		repetition := "1"
+		if field.Repeated {
+			repetition = "0..*"
+		} else if field.Optional {
+			repetition = "0..1"
+		}
+		row := table.Row{name, field.Type, field.Annotation, repetition}
 		if metadata != nil {
 			geoColumn, ok := metadata.Columns[field.Name]
 			if !ok {
 				row = append(row, "")
 			} else {
-				types := strings.Join(geoColumn.GetGeometryTypes(), ",\n")
+				types := strings.Join(geoColumn.GetGeometryTypes(), ", ")
 				bounds := ""
 				if geoColumn.Bounds != nil {
 					values := make([]string, len(geoColumn.Bounds))
 					for i, v := range geoColumn.Bounds {
 						values[i] = strconv.FormatFloat(v, 'f', -1, 64)
 					}
-					bounds = fmt.Sprintf("[%s]", strings.Join(values, ",\n"))
+					bounds = fmt.Sprintf("[%s]", strings.Join(values, ", "))
+				}
+				details := table.NewWriter()
+				details.SetStyle(table.StyleLight)
+				details.Style().Options.DrawBorder = false
+				if geoColumn.Orientation != "" {
+					details.AppendRow(table.Row{"orientation", geoColumn.Orientation})
+				}
+				if geoColumn.Edges != "" {
+					details.AppendRow(table.Row{"edges", geoColumn.Edges})
+				}
+				if geoColumn.CRS != nil {
+					details.AppendRow(table.Row{"crs", geoColumn.CRS})
 				}
-				row = append(row, geoColumn.Encoding, types, geoColumn.Orientation, geoColumn.Edges, bounds)
+				row = append(row, geoColumn.Encoding, types, bounds, details.Render())
 			}
 		}
 
 		tbl.AppendRow(row)
 	}
+
+	tbl.AppendFooter(makeFooter("Rows", info.NumRows, header), table.RowConfig{AutoMerge: true})
+	if metadata != nil {
+		version := metadata.Version
+		if version == "" {
+			version = "missing"
+		}
+		tbl.AppendFooter(makeFooter("Version", version, header), table.RowConfig{AutoMerge: true, AutoMergeAlign: text.AlignLeft})
+	}
+
 	tbl.SetStyle(table.StyleRounded)
+	tbl.SetOutputMirror(out)
 	tbl.Render()
 
-	plural := "s"
-	if info.NumRows == 1 {
-		plural = ""
-	}
-	fmt.Printf("%d row%s\n", info.NumRows, plural)
 	return nil
 }
 
+func makeFooter(key string, value any, header table.Row) table.Row {
+	row := table.Row{key, value}
+	for i := len(row); i < len(header); i += 1 {
+		row = append(row, "")
+	}
+	return row
+}
+
 func (c *DescribeCmd) formatJSON(info *Info) error {
 	encoder := json.NewEncoder(os.Stdout)
 	if !c.Unpretty {
diff --git a/go.mod b/go.mod
index 25457a3..6f6ea5a 100644
--- a/go.mod
+++ b/go.mod
@@ -11,6 +11,7 @@ require (
 	github.com/segmentio/encoding v0.3.6
 	github.com/segmentio/parquet-go v0.0.0-20230605165518-1fd7f3303070
 	github.com/stretchr/testify v1.8.4
+	golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1
 )
 
 require (
diff --git a/go.sum b/go.sum
index 725ee9b..732db9f 100644
--- a/go.sum
+++ b/go.sum
@@ -104,6 +104,7 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ=
 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1 h1:v+OssWQX+hTHEmOBgwxdZxK4zHq3yOs8F9J7mk0PY8E=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
diff --git a/internal/geoparquet/geoparquet.go b/internal/geoparquet/geoparquet.go
index af1b8c2..7d16342 100644
--- a/internal/geoparquet/geoparquet.go
+++ b/internal/geoparquet/geoparquet.go
@@ -71,11 +71,39 @@ func (m *Metadata) Clone() *Metadata {
 	return clone
 }
 
+type ProjId struct {
+	Authority string `json:"authority"`
+	Code      any    `json:"code"`
+}
+
+type Proj struct {
+	Name string  `json:"name"`
+	Id   *ProjId `json:"id"`
+}
+
+func (p *Proj) String() string {
+	id := ""
+	if p.Id != nil {
+		if code, ok := p.Id.Code.(string); ok {
+			id = p.Id.Authority + ":" + code
+		} else if code, ok := p.Id.Code.(float64); ok {
+			id = fmt.Sprintf("%s:%g", p.Id.Authority, code)
+		}
+	}
+	if p.Name != "" {
+		return p.Name
+	}
+	if id == "" {
+		return "Unknown"
+	}
+	return id
+}
+
 type GeometryColumn struct {
 	Encoding      string    `json:"encoding"`
 	GeometryType  any       `json:"geometry_type,omitempty"`
 	GeometryTypes any       `json:"geometry_types"`
-	CRS           any       `json:"crs,omitempty"`
+	CRS           *Proj     `json:"crs,omitempty"`
 	Edges         string    `json:"edges,omitempty"`
 	Orientation   string    `json:"orientation,omitempty"`
 	Bounds        []float64 `json:"bbox,omitempty"`


Test patch:
diff --git a/internal/geoparquet/geoparquet_test.go b/internal/geoparquet/geoparquet_test.go
index dd3d621..5c613c4 100644
--- a/internal/geoparquet/geoparquet_test.go
+++ b/internal/geoparquet/geoparquet_test.go
@@ -79,6 +79,12 @@ func TestGetMetadataV100Beta1(t *testing.T) {
 	assert.Equal(t, "planar", col.Edges)
 	assert.Equal(t, []float64{-180, -90, 180, 83.6451}, col.Bounds)
 	assert.Equal(t, []string{"Polygon", "MultiPolygon"}, col.GetGeometryTypes())
+
+	require.NotNil(t, col.CRS)
+	require.NotNil(t, col.CRS.Id)
+	assert.Equal(t, "OGC", col.CRS.Id.Authority)
+	assert.Equal(t, "CRS84", col.CRS.Id.Code)
+	assert.Equal(t, "WGS 84 (CRS84)", col.CRS.Name)
 }
 
 func TestRowReaderV040(t *testing.T) {