From 608b82130c1cb9e27d613f2c1ada5e24c3ec57c2 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Tue, 11 Aug 2026 15:52:04 +0200 Subject: [PATCH] Add the data spine: typed records, query engine, and Memory/gRPC proxies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The headless data layer of the go-widgets ecosystem. Pure Go, CGO=0; the core imports only the standard library and go-widgets/mvvm. Core (package data): - Value/Kind: a comparable typed scalar (string/int/float/bool). - Record/Schema/Field/Rule: a typed row and its validation (Required, StrMinLen/StrMaxLen, NumMin/NumMax), mirroring toolkit's validation shape lifted to typed values. - Query -> Apply -> View: a pure query engine — filter, sort (multi-key, asc/desc), group-by, paginate (rows or groups), and aggregate (count/sum/avg/min/max, grand + per-group). - Proxy seam (List/Query/Mutate) with MemoryProxy, the in-process reference backend. - Store[R]: a typed, bindable collection wired through mvvm.ObservableList, so a view re-renders when the data changes. - Canonical(View): a deterministic, map-order-independent encoding used to compare views byte for byte. Transport (package grpcproxy): - A small list/query/mutate .proto service (datapb, generated) carried over grpc-transports/websocket, so the client compiles to js/wasm and a browser speaks the same gRPC service. Server is native-only (//go:build !js); Client is a data.Proxy that builds everywhere. Proxy conformance (the key test): a battery of 15 sort->filter->group->page ->aggregate queries is run through the MemoryProxy, the remote GRPCProxy, and the engine directly; every resulting View canonicalises to identical bytes across all three. The same data code therefore yields identical results native and over the wire (browser/wasm) — proven, not asserted. 100% statement coverage on data/ and grpcproxy/ (generated datapb/ excepted); CI builds 6 arches + wasm + darwin + windows and runs the race detector. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 76 +++ README.md | 72 ++- canonical.go | 88 +++ canonical_test.go | 59 ++ datapb/data.pb.go | 1123 +++++++++++++++++++++++++++++++++ datapb/data_grpc.pb.go | 211 +++++++ engine.go | 192 ++++++ engine_test.go | 194 ++++++ go.mod | 18 + go.sum | 44 ++ grpcproxy/client.go | 77 +++ grpcproxy/conformance_test.go | 136 ++++ grpcproxy/conv.go | 197 ++++++ grpcproxy/grpcproxy_test.go | 185 ++++++ grpcproxy/server.go | 79 +++ memory.go | 114 ++++ memory_test.go | 106 ++++ proto/data.proto | 127 ++++ proto/generate.sh | 12 + proxy.go | 42 ++ query.go | 135 ++++ record.go | 147 +++++ record_test.go | 109 ++++ store.go | 97 +++ store_test.go | 135 ++++ value.go | 141 +++++ value_test.go | 68 ++ 27 files changed, 3983 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci.yml create mode 100644 canonical.go create mode 100644 canonical_test.go create mode 100644 datapb/data.pb.go create mode 100644 datapb/data_grpc.pb.go create mode 100644 engine.go create mode 100644 engine_test.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 grpcproxy/client.go create mode 100644 grpcproxy/conformance_test.go create mode 100644 grpcproxy/conv.go create mode 100644 grpcproxy/grpcproxy_test.go create mode 100644 grpcproxy/server.go create mode 100644 memory.go create mode 100644 memory_test.go create mode 100644 proto/data.proto create mode 100755 proto/generate.sh create mode 100644 proxy.go create mode 100644 query.go create mode 100644 record.go create mode 100644 record_test.go create mode 100644 store.go create mode 100644 store_test.go create mode 100644 value.go create mode 100644 value_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..cb01292 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,76 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +env: + GOWORK: "off" + CGO_ENABLED: "0" + +jobs: + test: + name: Build + test + 100% coverage gate + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.26.4" + check-latest: true + cache: true + + - name: gofmt + run: | + # The generated protobuf sources under datapb/ are gofmt-clean as + # emitted; everything else must be too. + unformatted=$(gofmt -l .) + [ -z "$unformatted" ] || { + echo "::error::not gofmt-clean:"; echo "$unformatted" + gofmt -d $unformatted; exit 1; } + + - name: go vet + run: go vet ./... + + - name: Test with coverage + run: go test -coverprofile=coverage.out ./... + + - name: Assert 100% statement coverage (excluding generated code) + run: | + # datapb/ is protoc-generated (DO NOT EDIT); its getters/reflection + # are not our statements to test, so drop them from the profile before + # gating. Every hand-written statement in data/ and grpcproxy/ must be + # 100% covered — no rounding. + grep -v 'github.com/go-widgets/data/datapb/' coverage.out > coverage.filtered + go tool cover -func=coverage.filtered + below=$(go tool cover -func=coverage.filtered | awk '$NF != "100.0%"') + if [ -n "$below" ]; then + echo "::error::coverage below 100% on:" + echo "$below" + exit 1 + fi + + - name: Race detector (CGO on, for -race only) + env: + CGO_ENABLED: "1" + run: go test -race ./... + + - name: Cross-compile smoke (6 64-bit arches + wasm + darwin + windows) + run: | + for arch in amd64 arm64 riscv64 loong64 ppc64le s390x; do + echo "== linux/$arch ==" + GOOS=linux GOARCH=$arch go build ./... + done + # The GRPCProxy client compiles to wasm (the server is native-only, + # gated //go:build !js) — this is what lets a browser run the same + # data pipeline. + GOOS=js GOARCH=wasm go build ./... + GOOS=darwin GOARCH=arm64 go build ./... + GOOS=windows GOARCH=amd64 go build ./... diff --git a/README.md b/README.md index 27571a7..5a10823 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,73 @@ # go-widgets/data -The headless data spine of the go-widgets UI ecosystem. See the initial pull request for the full implementation. +The headless **data spine** of the [go-widgets](https://github.com/go-widgets) +UI ecosystem: a typed record model with validation, a bindable collection with +sort / filter / group / pagination / aggregation, and a **pluggable proxy** so +the *very same* query pipeline runs in-process or against a remote service — +natively and in a browser/wasm build alike. + +Pure Go, `CGO=0`, BSD-3-Clause. The core package imports only the standard +library and [`go-widgets/mvvm`](https://github.com/go-widgets/mvvm); nothing here +depends on a GUI. + +## Layers + +| Piece | What it is | +|-------|-----------| +| `Value` / `Kind` | a comparable typed scalar (string / int / float / bool) | +| `Record` / `Schema` / `Field` / `Rule` | a typed row and its validation | +| `Query` → `Apply` → `View` | the pure query engine: filter, sort, group, page, aggregate | +| `Proxy` | the backend seam: `List` / `Query` / `Mutate` | +| `MemoryProxy` | the in-process reference backend | +| `grpcproxy.Server` / `grpcproxy.Client` | the same contract over gRPC, carried by [`grpc-transports/websocket`](https://github.com/grpc-transports/websocket) | +| `Store[R]` | a typed, bindable collection wired through `mvvm.ObservableList` | + +## Why a proxy seam + +`Store` talks to a `Proxy`; it never knows whether the data is a `MemoryProxy` in +the same process or a `grpcproxy.Client` reaching a server across a websocket. +Because the query engine (`Apply`) is a pure function of `(records, Query)`, the +client and the server run *the same code* on the same rows — so a `MemoryProxy` +and a `grpcproxy.Client` return **byte-identical** Views. + +That equality is asserted directly: `grpcproxy`'s conformance test runs a battery +of sort→filter→group→page→aggregate queries through both proxies and through the +engine, canonicalises every resulting `View`, and requires all three to be +identical byte for byte. The WebSocket transport compiles to `js/wasm`, so the +`grpcproxy.Client` is exactly what a go-widgets wasm app uses to speak this same +service from the browser — no second data path. + +## Example + +```go +schema := data.Schema{Fields: []data.Field{ + {Name: "id", Kind: data.KindInt}, + {Name: "name", Kind: data.KindString, Rules: []data.Rule{data.Required("name required")}}, + {Name: "salary", Kind: data.KindFloat}, +}} +mem, _ := data.NewMemoryProxy(schema, "id", + data.Record{"id": data.Int(1), "name": data.String("ann"), "salary": data.Float(30)}, +) + +type person struct{ ID int64; Name string; Salary float64 } +store := data.NewStore(mem, data.Codec[person]{ + Encode: func(p person) data.Record { + return data.Record{"id": data.Int(p.ID), "name": data.String(p.Name), "salary": data.Float(p.Salary)} + }, + Decode: func(r data.Record) person { + return person{ID: r["id"].Int, Name: r["name"].Str, Salary: r["salary"].Float} + }, +}) +store.SetQuery(data.Query{Sorts: []data.Sort{{Field: "salary", Desc: true}}, Limit: 20}) +store.Load(ctx) // runs the query, fills the ObservableList +_ = store.Items() // bind this to a view — it re-renders on change +``` + +Swap `mem` for a `grpcproxy.Client` and nothing else changes. + +## Status + +Core `data` and `grpcproxy` are at 100% statement coverage (the generated +`datapb/` protobuf code excepted); CI builds all six 64-bit Go targets plus +`js/wasm`, macOS and Windows, and runs the race detector. Org-conformance +landing/logo/docs are a follow-up. diff --git a/canonical.go b/canonical.go new file mode 100644 index 0000000..ac01172 --- /dev/null +++ b/canonical.go @@ -0,0 +1,88 @@ +// Copyright (c) 2026 the go-widgets/data authors. All rights reserved. +// Use of this source code is governed by a BSD-3-Clause license that can be +// found in the LICENSE file at the root of this repository. + +package data + +import ( + "sort" + "strconv" + "strings" +) + +// Canonical renders a View as a deterministic byte string: the same View value +// always yields the same bytes, and — crucially — a View computed by MemoryProxy +// and the identical View reconstructed from a grpcproxy round-trip encode to the +// SAME bytes. That is the equality the proxy-conformance test asserts. +// +// Determinism is achieved by never depending on Go map iteration order: record +// fields and aggregate labels are always emitted in sorted order, and rows and +// groups keep their query order. The format is unambiguous (length-tagged +// enough for the fields we carry), but it is meant for equality, not parsing. +func Canonical(v View) []byte { + var b strings.Builder + b.WriteString("total=") + b.WriteString(strconv.Itoa(v.Total)) + b.WriteByte('\n') + + b.WriteString("aggs=") + writeAggs(&b, v.Aggregates) + b.WriteByte('\n') + + if v.Groups != nil { + b.WriteString("groups=") + b.WriteString(strconv.Itoa(len(v.Groups))) + b.WriteByte('\n') + for _, g := range v.Groups { + b.WriteString("group key=") + b.WriteString(g.Key.canonical()) + b.WriteString(" aggs=") + writeAggs(&b, g.Aggregates) + b.WriteByte('\n') + writeRows(&b, g.Rows) + } + return []byte(b.String()) + } + + b.WriteString("rows=") + b.WriteString(strconv.Itoa(len(v.Rows))) + b.WriteByte('\n') + writeRows(&b, v.Rows) + return []byte(b.String()) +} + +// writeRows appends every row in order, each as its sorted-field canonical form. +func writeRows(b *strings.Builder, rows []Record) { + for _, r := range rows { + b.WriteString(" row") + for _, name := range r.sortedNames() { + b.WriteByte(' ') + b.WriteString(name) + b.WriteByte('=') + b.WriteString(r[name].canonical()) + } + b.WriteByte('\n') + } +} + +// writeAggs appends the aggregates in sorted-label order (or "-" when none), so +// the aggregate map never leaks its iteration order into the bytes. +func writeAggs(b *strings.Builder, aggs map[string]Value) { + if len(aggs) == 0 { + b.WriteByte('-') + return + } + labels := make([]string, 0, len(aggs)) + for k := range aggs { + labels = append(labels, k) + } + sort.Strings(labels) + for i, k := range labels { + if i > 0 { + b.WriteByte(',') + } + b.WriteString(k) + b.WriteByte('=') + b.WriteString(aggs[k].canonical()) + } +} diff --git a/canonical_test.go b/canonical_test.go new file mode 100644 index 0000000..a360db7 --- /dev/null +++ b/canonical_test.go @@ -0,0 +1,59 @@ +// Copyright (c) 2026 the go-widgets/data authors. All rights reserved. +// Use of this source code is governed by a BSD-3-Clause license that can be +// found in the LICENSE file at the root of this repository. + +package data + +import ( + "bytes" + "testing" +) + +func TestCanonicalIsOrderIndependent(t *testing.T) { + // Two records with the same content built in different insertion orders must + // canonicalise identically (fields are emitted sorted). + a := Record{"b": Int(2), "a": String("x"), "c": Bool(true)} + b := Record{"c": Bool(true), "a": String("x"), "b": Int(2)} + va := Canonical(View{Rows: []Record{a}, Total: 1}) + vb := Canonical(View{Rows: []Record{b}, Total: 1}) + if !bytes.Equal(va, vb) { + t.Fatalf("field order leaked:\n%s\n---\n%s", va, vb) + } +} + +func TestCanonicalUngroupedShape(t *testing.T) { + v := View{ + Rows: []Record{{"id": Int(1), "name": String("ann")}}, + Total: 1, + Aggregates: map[string]Value{"count": Int(1), "sum(x)": Float(2.5)}, + } + got := string(Canonical(v)) + want := "total=1\n" + + "aggs=count=i:1,sum(x)=f:2.5\n" + + "rows=1\n" + + " row id=i:1 name=s:ann\n" + if got != want { + t.Fatalf("canonical =\n%q\nwant\n%q", got, want) + } +} + +func TestCanonicalGroupedAndNoAggs(t *testing.T) { + v := View{ + Total: 2, + Groups: []Group{ + {Key: String("red"), Rows: []Record{{"id": Int(1)}}, Aggregates: map[string]Value{"count": Int(1)}}, + {Key: String("blue"), Rows: []Record{{"id": Int(2)}}, Aggregates: nil}, + }, + } + got := string(Canonical(v)) + want := "total=2\n" + + "aggs=-\n" + + "groups=2\n" + + "group key=s:red aggs=count=i:1\n" + + " row id=i:1\n" + + "group key=s:blue aggs=-\n" + + " row id=i:2\n" + if got != want { + t.Fatalf("grouped canonical =\n%q\nwant\n%q", got, want) + } +} diff --git a/datapb/data.pb.go b/datapb/data.pb.go new file mode 100644 index 0000000..c78a6e0 --- /dev/null +++ b/datapb/data.pb.go @@ -0,0 +1,1123 @@ +// Copyright (c) 2026 the go-widgets/data authors. All rights reserved. +// Use of this source code is governed by a BSD-3-Clause license that can be +// found in the LICENSE file at the root of this repository. + +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc v7.34.1 +// source: proto/data.proto + +package datapb + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// Kind mirrors data.Kind: the scalar type of a Value. +type Kind int32 + +const ( + Kind_KIND_STRING Kind = 0 + Kind_KIND_INT Kind = 1 + Kind_KIND_FLOAT Kind = 2 + Kind_KIND_BOOL Kind = 3 +) + +// Enum value maps for Kind. +var ( + Kind_name = map[int32]string{ + 0: "KIND_STRING", + 1: "KIND_INT", + 2: "KIND_FLOAT", + 3: "KIND_BOOL", + } + Kind_value = map[string]int32{ + "KIND_STRING": 0, + "KIND_INT": 1, + "KIND_FLOAT": 2, + "KIND_BOOL": 3, + } +) + +func (x Kind) Enum() *Kind { + p := new(Kind) + *p = x + return p +} + +func (x Kind) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (Kind) Descriptor() protoreflect.EnumDescriptor { + return file_proto_data_proto_enumTypes[0].Descriptor() +} + +func (Kind) Type() protoreflect.EnumType { + return &file_proto_data_proto_enumTypes[0] +} + +func (x Kind) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use Kind.Descriptor instead. +func (Kind) EnumDescriptor() ([]byte, []int) { + return file_proto_data_proto_rawDescGZIP(), []int{0} +} + +// FilterOp mirrors data.FilterOp. +type FilterOp int32 + +const ( + FilterOp_OP_EQ FilterOp = 0 + FilterOp_OP_NE FilterOp = 1 + FilterOp_OP_LT FilterOp = 2 + FilterOp_OP_LE FilterOp = 3 + FilterOp_OP_GT FilterOp = 4 + FilterOp_OP_GE FilterOp = 5 + FilterOp_OP_CONTAINS FilterOp = 6 +) + +// Enum value maps for FilterOp. +var ( + FilterOp_name = map[int32]string{ + 0: "OP_EQ", + 1: "OP_NE", + 2: "OP_LT", + 3: "OP_LE", + 4: "OP_GT", + 5: "OP_GE", + 6: "OP_CONTAINS", + } + FilterOp_value = map[string]int32{ + "OP_EQ": 0, + "OP_NE": 1, + "OP_LT": 2, + "OP_LE": 3, + "OP_GT": 4, + "OP_GE": 5, + "OP_CONTAINS": 6, + } +) + +func (x FilterOp) Enum() *FilterOp { + p := new(FilterOp) + *p = x + return p +} + +func (x FilterOp) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (FilterOp) Descriptor() protoreflect.EnumDescriptor { + return file_proto_data_proto_enumTypes[1].Descriptor() +} + +func (FilterOp) Type() protoreflect.EnumType { + return &file_proto_data_proto_enumTypes[1] +} + +func (x FilterOp) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use FilterOp.Descriptor instead. +func (FilterOp) EnumDescriptor() ([]byte, []int) { + return file_proto_data_proto_rawDescGZIP(), []int{1} +} + +// AggFunc mirrors data.AggFunc. +type AggFunc int32 + +const ( + AggFunc_AGG_COUNT AggFunc = 0 + AggFunc_AGG_SUM AggFunc = 1 + AggFunc_AGG_AVG AggFunc = 2 + AggFunc_AGG_MIN AggFunc = 3 + AggFunc_AGG_MAX AggFunc = 4 +) + +// Enum value maps for AggFunc. +var ( + AggFunc_name = map[int32]string{ + 0: "AGG_COUNT", + 1: "AGG_SUM", + 2: "AGG_AVG", + 3: "AGG_MIN", + 4: "AGG_MAX", + } + AggFunc_value = map[string]int32{ + "AGG_COUNT": 0, + "AGG_SUM": 1, + "AGG_AVG": 2, + "AGG_MIN": 3, + "AGG_MAX": 4, + } +) + +func (x AggFunc) Enum() *AggFunc { + p := new(AggFunc) + *p = x + return p +} + +func (x AggFunc) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (AggFunc) Descriptor() protoreflect.EnumDescriptor { + return file_proto_data_proto_enumTypes[2].Descriptor() +} + +func (AggFunc) Type() protoreflect.EnumType { + return &file_proto_data_proto_enumTypes[2] +} + +func (x AggFunc) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use AggFunc.Descriptor instead. +func (AggFunc) EnumDescriptor() ([]byte, []int) { + return file_proto_data_proto_rawDescGZIP(), []int{2} +} + +// MutationKind mirrors data.MutationKind. +type MutationKind int32 + +const ( + MutationKind_MUT_INSERT MutationKind = 0 + MutationKind_MUT_UPDATE MutationKind = 1 + MutationKind_MUT_DELETE MutationKind = 2 +) + +// Enum value maps for MutationKind. +var ( + MutationKind_name = map[int32]string{ + 0: "MUT_INSERT", + 1: "MUT_UPDATE", + 2: "MUT_DELETE", + } + MutationKind_value = map[string]int32{ + "MUT_INSERT": 0, + "MUT_UPDATE": 1, + "MUT_DELETE": 2, + } +) + +func (x MutationKind) Enum() *MutationKind { + p := new(MutationKind) + *p = x + return p +} + +func (x MutationKind) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (MutationKind) Descriptor() protoreflect.EnumDescriptor { + return file_proto_data_proto_enumTypes[3].Descriptor() +} + +func (MutationKind) Type() protoreflect.EnumType { + return &file_proto_data_proto_enumTypes[3] +} + +func (x MutationKind) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use MutationKind.Descriptor instead. +func (MutationKind) EnumDescriptor() ([]byte, []int) { + return file_proto_data_proto_rawDescGZIP(), []int{3} +} + +// Value mirrors data.Value: a tagged scalar. Only the field named by kind is +// meaningful. +type Value struct { + state protoimpl.MessageState `protogen:"open.v1"` + Kind Kind `protobuf:"varint,1,opt,name=kind,proto3,enum=datapb.Kind" json:"kind,omitempty"` + Str string `protobuf:"bytes,2,opt,name=str,proto3" json:"str,omitempty"` + Int int64 `protobuf:"varint,3,opt,name=int,proto3" json:"int,omitempty"` + Float float64 `protobuf:"fixed64,4,opt,name=float,proto3" json:"float,omitempty"` + Bool bool `protobuf:"varint,5,opt,name=bool,proto3" json:"bool,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Value) Reset() { + *x = Value{} + mi := &file_proto_data_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Value) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Value) ProtoMessage() {} + +func (x *Value) ProtoReflect() protoreflect.Message { + mi := &file_proto_data_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Value.ProtoReflect.Descriptor instead. +func (*Value) Descriptor() ([]byte, []int) { + return file_proto_data_proto_rawDescGZIP(), []int{0} +} + +func (x *Value) GetKind() Kind { + if x != nil { + return x.Kind + } + return Kind_KIND_STRING +} + +func (x *Value) GetStr() string { + if x != nil { + return x.Str + } + return "" +} + +func (x *Value) GetInt() int64 { + if x != nil { + return x.Int + } + return 0 +} + +func (x *Value) GetFloat() float64 { + if x != nil { + return x.Float + } + return 0 +} + +func (x *Value) GetBool() bool { + if x != nil { + return x.Bool + } + return false +} + +// Record mirrors data.Record: a row of named cells. +type Record struct { + state protoimpl.MessageState `protogen:"open.v1"` + Fields map[string]*Value `protobuf:"bytes,1,rep,name=fields,proto3" json:"fields,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Record) Reset() { + *x = Record{} + mi := &file_proto_data_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Record) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Record) ProtoMessage() {} + +func (x *Record) ProtoReflect() protoreflect.Message { + mi := &file_proto_data_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Record.ProtoReflect.Descriptor instead. +func (*Record) Descriptor() ([]byte, []int) { + return file_proto_data_proto_rawDescGZIP(), []int{1} +} + +func (x *Record) GetFields() map[string]*Value { + if x != nil { + return x.Fields + } + return nil +} + +type Filter struct { + state protoimpl.MessageState `protogen:"open.v1"` + Field string `protobuf:"bytes,1,opt,name=field,proto3" json:"field,omitempty"` + Op FilterOp `protobuf:"varint,2,opt,name=op,proto3,enum=datapb.FilterOp" json:"op,omitempty"` + Value *Value `protobuf:"bytes,3,opt,name=value,proto3" json:"value,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Filter) Reset() { + *x = Filter{} + mi := &file_proto_data_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Filter) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Filter) ProtoMessage() {} + +func (x *Filter) ProtoReflect() protoreflect.Message { + mi := &file_proto_data_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Filter.ProtoReflect.Descriptor instead. +func (*Filter) Descriptor() ([]byte, []int) { + return file_proto_data_proto_rawDescGZIP(), []int{2} +} + +func (x *Filter) GetField() string { + if x != nil { + return x.Field + } + return "" +} + +func (x *Filter) GetOp() FilterOp { + if x != nil { + return x.Op + } + return FilterOp_OP_EQ +} + +func (x *Filter) GetValue() *Value { + if x != nil { + return x.Value + } + return nil +} + +type Sort struct { + state protoimpl.MessageState `protogen:"open.v1"` + Field string `protobuf:"bytes,1,opt,name=field,proto3" json:"field,omitempty"` + Desc bool `protobuf:"varint,2,opt,name=desc,proto3" json:"desc,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Sort) Reset() { + *x = Sort{} + mi := &file_proto_data_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Sort) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Sort) ProtoMessage() {} + +func (x *Sort) ProtoReflect() protoreflect.Message { + mi := &file_proto_data_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Sort.ProtoReflect.Descriptor instead. +func (*Sort) Descriptor() ([]byte, []int) { + return file_proto_data_proto_rawDescGZIP(), []int{3} +} + +func (x *Sort) GetField() string { + if x != nil { + return x.Field + } + return "" +} + +func (x *Sort) GetDesc() bool { + if x != nil { + return x.Desc + } + return false +} + +type Agg struct { + state protoimpl.MessageState `protogen:"open.v1"` + Field string `protobuf:"bytes,1,opt,name=field,proto3" json:"field,omitempty"` + Func AggFunc `protobuf:"varint,2,opt,name=func,proto3,enum=datapb.AggFunc" json:"func,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Agg) Reset() { + *x = Agg{} + mi := &file_proto_data_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Agg) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Agg) ProtoMessage() {} + +func (x *Agg) ProtoReflect() protoreflect.Message { + mi := &file_proto_data_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Agg.ProtoReflect.Descriptor instead. +func (*Agg) Descriptor() ([]byte, []int) { + return file_proto_data_proto_rawDescGZIP(), []int{4} +} + +func (x *Agg) GetField() string { + if x != nil { + return x.Field + } + return "" +} + +func (x *Agg) GetFunc() AggFunc { + if x != nil { + return x.Func + } + return AggFunc_AGG_COUNT +} + +// Query mirrors data.Query: filter, sort, group, page, aggregate. +type Query struct { + state protoimpl.MessageState `protogen:"open.v1"` + Filters []*Filter `protobuf:"bytes,1,rep,name=filters,proto3" json:"filters,omitempty"` + Sorts []*Sort `protobuf:"bytes,2,rep,name=sorts,proto3" json:"sorts,omitempty"` + GroupBy string `protobuf:"bytes,3,opt,name=group_by,json=groupBy,proto3" json:"group_by,omitempty"` + Offset int32 `protobuf:"varint,4,opt,name=offset,proto3" json:"offset,omitempty"` + Limit int32 `protobuf:"varint,5,opt,name=limit,proto3" json:"limit,omitempty"` + Aggs []*Agg `protobuf:"bytes,6,rep,name=aggs,proto3" json:"aggs,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Query) Reset() { + *x = Query{} + mi := &file_proto_data_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Query) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Query) ProtoMessage() {} + +func (x *Query) ProtoReflect() protoreflect.Message { + mi := &file_proto_data_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Query.ProtoReflect.Descriptor instead. +func (*Query) Descriptor() ([]byte, []int) { + return file_proto_data_proto_rawDescGZIP(), []int{5} +} + +func (x *Query) GetFilters() []*Filter { + if x != nil { + return x.Filters + } + return nil +} + +func (x *Query) GetSorts() []*Sort { + if x != nil { + return x.Sorts + } + return nil +} + +func (x *Query) GetGroupBy() string { + if x != nil { + return x.GroupBy + } + return "" +} + +func (x *Query) GetOffset() int32 { + if x != nil { + return x.Offset + } + return 0 +} + +func (x *Query) GetLimit() int32 { + if x != nil { + return x.Limit + } + return 0 +} + +func (x *Query) GetAggs() []*Agg { + if x != nil { + return x.Aggs + } + return nil +} + +// Group mirrors data.Group. +type Group struct { + state protoimpl.MessageState `protogen:"open.v1"` + Key *Value `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` + Rows []*Record `protobuf:"bytes,2,rep,name=rows,proto3" json:"rows,omitempty"` + Aggregates map[string]*Value `protobuf:"bytes,3,rep,name=aggregates,proto3" json:"aggregates,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Group) Reset() { + *x = Group{} + mi := &file_proto_data_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Group) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Group) ProtoMessage() {} + +func (x *Group) ProtoReflect() protoreflect.Message { + mi := &file_proto_data_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Group.ProtoReflect.Descriptor instead. +func (*Group) Descriptor() ([]byte, []int) { + return file_proto_data_proto_rawDescGZIP(), []int{6} +} + +func (x *Group) GetKey() *Value { + if x != nil { + return x.Key + } + return nil +} + +func (x *Group) GetRows() []*Record { + if x != nil { + return x.Rows + } + return nil +} + +func (x *Group) GetAggregates() map[string]*Value { + if x != nil { + return x.Aggregates + } + return nil +} + +// View mirrors data.View. The grouped flag distinguishes a grouped view (whose +// Rows is nil and Groups non-nil) from an ungrouped one, which repeated fields +// alone cannot carry across the wire. +type View struct { + state protoimpl.MessageState `protogen:"open.v1"` + Rows []*Record `protobuf:"bytes,1,rep,name=rows,proto3" json:"rows,omitempty"` + Groups []*Group `protobuf:"bytes,2,rep,name=groups,proto3" json:"groups,omitempty"` + Total int32 `protobuf:"varint,3,opt,name=total,proto3" json:"total,omitempty"` + Aggregates map[string]*Value `protobuf:"bytes,4,rep,name=aggregates,proto3" json:"aggregates,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + Grouped bool `protobuf:"varint,5,opt,name=grouped,proto3" json:"grouped,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *View) Reset() { + *x = View{} + mi := &file_proto_data_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *View) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*View) ProtoMessage() {} + +func (x *View) ProtoReflect() protoreflect.Message { + mi := &file_proto_data_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use View.ProtoReflect.Descriptor instead. +func (*View) Descriptor() ([]byte, []int) { + return file_proto_data_proto_rawDescGZIP(), []int{7} +} + +func (x *View) GetRows() []*Record { + if x != nil { + return x.Rows + } + return nil +} + +func (x *View) GetGroups() []*Group { + if x != nil { + return x.Groups + } + return nil +} + +func (x *View) GetTotal() int32 { + if x != nil { + return x.Total + } + return 0 +} + +func (x *View) GetAggregates() map[string]*Value { + if x != nil { + return x.Aggregates + } + return nil +} + +func (x *View) GetGrouped() bool { + if x != nil { + return x.Grouped + } + return false +} + +type Mutation struct { + state protoimpl.MessageState `protogen:"open.v1"` + Kind MutationKind `protobuf:"varint,1,opt,name=kind,proto3,enum=datapb.MutationKind" json:"kind,omitempty"` + Record *Record `protobuf:"bytes,2,opt,name=record,proto3" json:"record,omitempty"` + Key *Value `protobuf:"bytes,3,opt,name=key,proto3" json:"key,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Mutation) Reset() { + *x = Mutation{} + mi := &file_proto_data_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Mutation) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Mutation) ProtoMessage() {} + +func (x *Mutation) ProtoReflect() protoreflect.Message { + mi := &file_proto_data_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Mutation.ProtoReflect.Descriptor instead. +func (*Mutation) Descriptor() ([]byte, []int) { + return file_proto_data_proto_rawDescGZIP(), []int{8} +} + +func (x *Mutation) GetKind() MutationKind { + if x != nil { + return x.Kind + } + return MutationKind_MUT_INSERT +} + +func (x *Mutation) GetRecord() *Record { + if x != nil { + return x.Record + } + return nil +} + +func (x *Mutation) GetKey() *Value { + if x != nil { + return x.Key + } + return nil +} + +type ListRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListRequest) Reset() { + *x = ListRequest{} + mi := &file_proto_data_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListRequest) ProtoMessage() {} + +func (x *ListRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_data_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListRequest.ProtoReflect.Descriptor instead. +func (*ListRequest) Descriptor() ([]byte, []int) { + return file_proto_data_proto_rawDescGZIP(), []int{9} +} + +type ListResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Records []*Record `protobuf:"bytes,1,rep,name=records,proto3" json:"records,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListResponse) Reset() { + *x = ListResponse{} + mi := &file_proto_data_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListResponse) ProtoMessage() {} + +func (x *ListResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_data_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListResponse.ProtoReflect.Descriptor instead. +func (*ListResponse) Descriptor() ([]byte, []int) { + return file_proto_data_proto_rawDescGZIP(), []int{10} +} + +func (x *ListResponse) GetRecords() []*Record { + if x != nil { + return x.Records + } + return nil +} + +type MutateResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MutateResponse) Reset() { + *x = MutateResponse{} + mi := &file_proto_data_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MutateResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MutateResponse) ProtoMessage() {} + +func (x *MutateResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_data_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MutateResponse.ProtoReflect.Descriptor instead. +func (*MutateResponse) Descriptor() ([]byte, []int) { + return file_proto_data_proto_rawDescGZIP(), []int{11} +} + +var File_proto_data_proto protoreflect.FileDescriptor + +const file_proto_data_proto_rawDesc = "" + + "\n" + + "\x10proto/data.proto\x12\x06datapb\"w\n" + + "\x05Value\x12 \n" + + "\x04kind\x18\x01 \x01(\x0e2\f.datapb.KindR\x04kind\x12\x10\n" + + "\x03str\x18\x02 \x01(\tR\x03str\x12\x10\n" + + "\x03int\x18\x03 \x01(\x03R\x03int\x12\x14\n" + + "\x05float\x18\x04 \x01(\x01R\x05float\x12\x12\n" + + "\x04bool\x18\x05 \x01(\bR\x04bool\"\x86\x01\n" + + "\x06Record\x122\n" + + "\x06fields\x18\x01 \x03(\v2\x1a.datapb.Record.FieldsEntryR\x06fields\x1aH\n" + + "\vFieldsEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12#\n" + + "\x05value\x18\x02 \x01(\v2\r.datapb.ValueR\x05value:\x028\x01\"e\n" + + "\x06Filter\x12\x14\n" + + "\x05field\x18\x01 \x01(\tR\x05field\x12 \n" + + "\x02op\x18\x02 \x01(\x0e2\x10.datapb.FilterOpR\x02op\x12#\n" + + "\x05value\x18\x03 \x01(\v2\r.datapb.ValueR\x05value\"0\n" + + "\x04Sort\x12\x14\n" + + "\x05field\x18\x01 \x01(\tR\x05field\x12\x12\n" + + "\x04desc\x18\x02 \x01(\bR\x04desc\"@\n" + + "\x03Agg\x12\x14\n" + + "\x05field\x18\x01 \x01(\tR\x05field\x12#\n" + + "\x04func\x18\x02 \x01(\x0e2\x0f.datapb.AggFuncR\x04func\"\xbf\x01\n" + + "\x05Query\x12(\n" + + "\afilters\x18\x01 \x03(\v2\x0e.datapb.FilterR\afilters\x12\"\n" + + "\x05sorts\x18\x02 \x03(\v2\f.datapb.SortR\x05sorts\x12\x19\n" + + "\bgroup_by\x18\x03 \x01(\tR\agroupBy\x12\x16\n" + + "\x06offset\x18\x04 \x01(\x05R\x06offset\x12\x14\n" + + "\x05limit\x18\x05 \x01(\x05R\x05limit\x12\x1f\n" + + "\x04aggs\x18\x06 \x03(\v2\v.datapb.AggR\x04aggs\"\xd9\x01\n" + + "\x05Group\x12\x1f\n" + + "\x03key\x18\x01 \x01(\v2\r.datapb.ValueR\x03key\x12\"\n" + + "\x04rows\x18\x02 \x03(\v2\x0e.datapb.RecordR\x04rows\x12=\n" + + "\n" + + "aggregates\x18\x03 \x03(\v2\x1d.datapb.Group.AggregatesEntryR\n" + + "aggregates\x1aL\n" + + "\x0fAggregatesEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12#\n" + + "\x05value\x18\x02 \x01(\v2\r.datapb.ValueR\x05value:\x028\x01\"\x8d\x02\n" + + "\x04View\x12\"\n" + + "\x04rows\x18\x01 \x03(\v2\x0e.datapb.RecordR\x04rows\x12%\n" + + "\x06groups\x18\x02 \x03(\v2\r.datapb.GroupR\x06groups\x12\x14\n" + + "\x05total\x18\x03 \x01(\x05R\x05total\x12<\n" + + "\n" + + "aggregates\x18\x04 \x03(\v2\x1c.datapb.View.AggregatesEntryR\n" + + "aggregates\x12\x18\n" + + "\agrouped\x18\x05 \x01(\bR\agrouped\x1aL\n" + + "\x0fAggregatesEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12#\n" + + "\x05value\x18\x02 \x01(\v2\r.datapb.ValueR\x05value:\x028\x01\"}\n" + + "\bMutation\x12(\n" + + "\x04kind\x18\x01 \x01(\x0e2\x14.datapb.MutationKindR\x04kind\x12&\n" + + "\x06record\x18\x02 \x01(\v2\x0e.datapb.RecordR\x06record\x12\x1f\n" + + "\x03key\x18\x03 \x01(\v2\r.datapb.ValueR\x03key\"\r\n" + + "\vListRequest\"8\n" + + "\fListResponse\x12(\n" + + "\arecords\x18\x01 \x03(\v2\x0e.datapb.RecordR\arecords\"\x10\n" + + "\x0eMutateResponse*D\n" + + "\x04Kind\x12\x0f\n" + + "\vKIND_STRING\x10\x00\x12\f\n" + + "\bKIND_INT\x10\x01\x12\x0e\n" + + "\n" + + "KIND_FLOAT\x10\x02\x12\r\n" + + "\tKIND_BOOL\x10\x03*]\n" + + "\bFilterOp\x12\t\n" + + "\x05OP_EQ\x10\x00\x12\t\n" + + "\x05OP_NE\x10\x01\x12\t\n" + + "\x05OP_LT\x10\x02\x12\t\n" + + "\x05OP_LE\x10\x03\x12\t\n" + + "\x05OP_GT\x10\x04\x12\t\n" + + "\x05OP_GE\x10\x05\x12\x0f\n" + + "\vOP_CONTAINS\x10\x06*L\n" + + "\aAggFunc\x12\r\n" + + "\tAGG_COUNT\x10\x00\x12\v\n" + + "\aAGG_SUM\x10\x01\x12\v\n" + + "\aAGG_AVG\x10\x02\x12\v\n" + + "\aAGG_MIN\x10\x03\x12\v\n" + + "\aAGG_MAX\x10\x04*>\n" + + "\fMutationKind\x12\x0e\n" + + "\n" + + "MUT_INSERT\x10\x00\x12\x0e\n" + + "\n" + + "MUT_UPDATE\x10\x01\x12\x0e\n" + + "\n" + + "MUT_DELETE\x10\x022\x9a\x01\n" + + "\vDataService\x121\n" + + "\x04List\x12\x13.datapb.ListRequest\x1a\x14.datapb.ListResponse\x12$\n" + + "\x05Query\x12\r.datapb.Query\x1a\f.datapb.View\x122\n" + + "\x06Mutate\x12\x10.datapb.Mutation\x1a\x16.datapb.MutateResponseB#Z!github.com/go-widgets/data/datapbb\x06proto3" + +var ( + file_proto_data_proto_rawDescOnce sync.Once + file_proto_data_proto_rawDescData []byte +) + +func file_proto_data_proto_rawDescGZIP() []byte { + file_proto_data_proto_rawDescOnce.Do(func() { + file_proto_data_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_proto_data_proto_rawDesc), len(file_proto_data_proto_rawDesc))) + }) + return file_proto_data_proto_rawDescData +} + +var file_proto_data_proto_enumTypes = make([]protoimpl.EnumInfo, 4) +var file_proto_data_proto_msgTypes = make([]protoimpl.MessageInfo, 15) +var file_proto_data_proto_goTypes = []any{ + (Kind)(0), // 0: datapb.Kind + (FilterOp)(0), // 1: datapb.FilterOp + (AggFunc)(0), // 2: datapb.AggFunc + (MutationKind)(0), // 3: datapb.MutationKind + (*Value)(nil), // 4: datapb.Value + (*Record)(nil), // 5: datapb.Record + (*Filter)(nil), // 6: datapb.Filter + (*Sort)(nil), // 7: datapb.Sort + (*Agg)(nil), // 8: datapb.Agg + (*Query)(nil), // 9: datapb.Query + (*Group)(nil), // 10: datapb.Group + (*View)(nil), // 11: datapb.View + (*Mutation)(nil), // 12: datapb.Mutation + (*ListRequest)(nil), // 13: datapb.ListRequest + (*ListResponse)(nil), // 14: datapb.ListResponse + (*MutateResponse)(nil), // 15: datapb.MutateResponse + nil, // 16: datapb.Record.FieldsEntry + nil, // 17: datapb.Group.AggregatesEntry + nil, // 18: datapb.View.AggregatesEntry +} +var file_proto_data_proto_depIdxs = []int32{ + 0, // 0: datapb.Value.kind:type_name -> datapb.Kind + 16, // 1: datapb.Record.fields:type_name -> datapb.Record.FieldsEntry + 1, // 2: datapb.Filter.op:type_name -> datapb.FilterOp + 4, // 3: datapb.Filter.value:type_name -> datapb.Value + 2, // 4: datapb.Agg.func:type_name -> datapb.AggFunc + 6, // 5: datapb.Query.filters:type_name -> datapb.Filter + 7, // 6: datapb.Query.sorts:type_name -> datapb.Sort + 8, // 7: datapb.Query.aggs:type_name -> datapb.Agg + 4, // 8: datapb.Group.key:type_name -> datapb.Value + 5, // 9: datapb.Group.rows:type_name -> datapb.Record + 17, // 10: datapb.Group.aggregates:type_name -> datapb.Group.AggregatesEntry + 5, // 11: datapb.View.rows:type_name -> datapb.Record + 10, // 12: datapb.View.groups:type_name -> datapb.Group + 18, // 13: datapb.View.aggregates:type_name -> datapb.View.AggregatesEntry + 3, // 14: datapb.Mutation.kind:type_name -> datapb.MutationKind + 5, // 15: datapb.Mutation.record:type_name -> datapb.Record + 4, // 16: datapb.Mutation.key:type_name -> datapb.Value + 5, // 17: datapb.ListResponse.records:type_name -> datapb.Record + 4, // 18: datapb.Record.FieldsEntry.value:type_name -> datapb.Value + 4, // 19: datapb.Group.AggregatesEntry.value:type_name -> datapb.Value + 4, // 20: datapb.View.AggregatesEntry.value:type_name -> datapb.Value + 13, // 21: datapb.DataService.List:input_type -> datapb.ListRequest + 9, // 22: datapb.DataService.Query:input_type -> datapb.Query + 12, // 23: datapb.DataService.Mutate:input_type -> datapb.Mutation + 14, // 24: datapb.DataService.List:output_type -> datapb.ListResponse + 11, // 25: datapb.DataService.Query:output_type -> datapb.View + 15, // 26: datapb.DataService.Mutate:output_type -> datapb.MutateResponse + 24, // [24:27] is the sub-list for method output_type + 21, // [21:24] is the sub-list for method input_type + 21, // [21:21] is the sub-list for extension type_name + 21, // [21:21] is the sub-list for extension extendee + 0, // [0:21] is the sub-list for field type_name +} + +func init() { file_proto_data_proto_init() } +func file_proto_data_proto_init() { + if File_proto_data_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_data_proto_rawDesc), len(file_proto_data_proto_rawDesc)), + NumEnums: 4, + NumMessages: 15, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_proto_data_proto_goTypes, + DependencyIndexes: file_proto_data_proto_depIdxs, + EnumInfos: file_proto_data_proto_enumTypes, + MessageInfos: file_proto_data_proto_msgTypes, + }.Build() + File_proto_data_proto = out.File + file_proto_data_proto_goTypes = nil + file_proto_data_proto_depIdxs = nil +} diff --git a/datapb/data_grpc.pb.go b/datapb/data_grpc.pb.go new file mode 100644 index 0000000..228ca6a --- /dev/null +++ b/datapb/data_grpc.pb.go @@ -0,0 +1,211 @@ +// Copyright (c) 2026 the go-widgets/data authors. All rights reserved. +// Use of this source code is governed by a BSD-3-Clause license that can be +// found in the LICENSE file at the root of this repository. + +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.5.1 +// - protoc v7.34.1 +// source: proto/data.proto + +package datapb + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + DataService_List_FullMethodName = "/datapb.DataService/List" + DataService_Query_FullMethodName = "/datapb.DataService/Query" + DataService_Mutate_FullMethodName = "/datapb.DataService/Mutate" +) + +// DataServiceClient is the client API for DataService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// DataService is the small list/query/mutate contract a grpcproxy.Client speaks +// to a grpcproxy.Server. The Server delegates each call to any data.Proxy (a +// MemoryProxy in practice), so the same query pipeline runs server-side and the +// client gets back the identical View. +type DataServiceClient interface { + List(ctx context.Context, in *ListRequest, opts ...grpc.CallOption) (*ListResponse, error) + Query(ctx context.Context, in *Query, opts ...grpc.CallOption) (*View, error) + Mutate(ctx context.Context, in *Mutation, opts ...grpc.CallOption) (*MutateResponse, error) +} + +type dataServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewDataServiceClient(cc grpc.ClientConnInterface) DataServiceClient { + return &dataServiceClient{cc} +} + +func (c *dataServiceClient) List(ctx context.Context, in *ListRequest, opts ...grpc.CallOption) (*ListResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListResponse) + err := c.cc.Invoke(ctx, DataService_List_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *dataServiceClient) Query(ctx context.Context, in *Query, opts ...grpc.CallOption) (*View, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(View) + err := c.cc.Invoke(ctx, DataService_Query_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *dataServiceClient) Mutate(ctx context.Context, in *Mutation, opts ...grpc.CallOption) (*MutateResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(MutateResponse) + err := c.cc.Invoke(ctx, DataService_Mutate_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// DataServiceServer is the server API for DataService service. +// All implementations must embed UnimplementedDataServiceServer +// for forward compatibility. +// +// DataService is the small list/query/mutate contract a grpcproxy.Client speaks +// to a grpcproxy.Server. The Server delegates each call to any data.Proxy (a +// MemoryProxy in practice), so the same query pipeline runs server-side and the +// client gets back the identical View. +type DataServiceServer interface { + List(context.Context, *ListRequest) (*ListResponse, error) + Query(context.Context, *Query) (*View, error) + Mutate(context.Context, *Mutation) (*MutateResponse, error) + mustEmbedUnimplementedDataServiceServer() +} + +// UnimplementedDataServiceServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedDataServiceServer struct{} + +func (UnimplementedDataServiceServer) List(context.Context, *ListRequest) (*ListResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method List not implemented") +} +func (UnimplementedDataServiceServer) Query(context.Context, *Query) (*View, error) { + return nil, status.Errorf(codes.Unimplemented, "method Query not implemented") +} +func (UnimplementedDataServiceServer) Mutate(context.Context, *Mutation) (*MutateResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method Mutate not implemented") +} +func (UnimplementedDataServiceServer) mustEmbedUnimplementedDataServiceServer() {} +func (UnimplementedDataServiceServer) testEmbeddedByValue() {} + +// UnsafeDataServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to DataServiceServer will +// result in compilation errors. +type UnsafeDataServiceServer interface { + mustEmbedUnimplementedDataServiceServer() +} + +func RegisterDataServiceServer(s grpc.ServiceRegistrar, srv DataServiceServer) { + // If the following call pancis, it indicates UnimplementedDataServiceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&DataService_ServiceDesc, srv) +} + +func _DataService_List_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DataServiceServer).List(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DataService_List_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DataServiceServer).List(ctx, req.(*ListRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _DataService_Query_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(Query) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DataServiceServer).Query(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DataService_Query_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DataServiceServer).Query(ctx, req.(*Query)) + } + return interceptor(ctx, in, info, handler) +} + +func _DataService_Mutate_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(Mutation) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(DataServiceServer).Mutate(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: DataService_Mutate_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(DataServiceServer).Mutate(ctx, req.(*Mutation)) + } + return interceptor(ctx, in, info, handler) +} + +// DataService_ServiceDesc is the grpc.ServiceDesc for DataService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var DataService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "datapb.DataService", + HandlerType: (*DataServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "List", + Handler: _DataService_List_Handler, + }, + { + MethodName: "Query", + Handler: _DataService_Query_Handler, + }, + { + MethodName: "Mutate", + Handler: _DataService_Mutate_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "proto/data.proto", +} diff --git a/engine.go b/engine.go new file mode 100644 index 0000000..f518b3d --- /dev/null +++ b/engine.go @@ -0,0 +1,192 @@ +// Copyright (c) 2026 the go-widgets/data authors. All rights reserved. +// Use of this source code is governed by a BSD-3-Clause license that can be +// found in the LICENSE file at the root of this repository. + +package data + +import ( + "sort" + "strings" +) + +// Apply runs a Query over records and returns the resulting View. It is a pure +// function of its inputs — no clock, no map-order dependence, no mutation of +// records — which is exactly why a MemoryProxy and a remote grpcproxy can share +// it and produce byte-identical Views: the client and the server call the same +// Apply on the same rows with the same Query. +// +// The pipeline is: filter, then compute the grand aggregates over the filtered +// set, then sort, then either group (and paginate the groups) or paginate the +// rows. Sorting before grouping keeps each group's rows in the query's order and +// makes the group order itself deterministic. +func Apply(records []Record, q Query) View { + filtered := filter(records, q.Filters) + view := View{ + Total: len(filtered), + Aggregates: aggregate(filtered, q.Aggs), + } + sortRows(filtered, q.Sorts) + + if q.GroupBy == "" { + lo, hi := pageBounds(len(filtered), q.Offset, q.Limit) + view.Rows = cloneRows(filtered[lo:hi]) + return view + } + + groups := group(filtered, q.GroupBy, q.Aggs) + lo, hi := pageBounds(len(groups), q.Offset, q.Limit) + view.Groups = groups[lo:hi] + return view +} + +// filter keeps the rows satisfying every predicate (ANDed). +func filter(records []Record, filters []Filter) []Record { + out := make([]Record, 0, len(records)) + for _, r := range records { + if matchesAll(r, filters) { + out = append(out, r) + } + } + return out +} + +// matchesAll reports whether r satisfies all filters. +func matchesAll(r Record, filters []Filter) bool { + for _, f := range filters { + if !match(r, f) { + return false + } + } + return true +} + +// match evaluates one predicate against a row. A missing cell never matches. +func match(r Record, f Filter) bool { + cell, ok := r[f.Field] + if !ok { + return false + } + switch f.Op { + case OpEq: + return cell.compare(f.Value) == 0 + case OpNe: + return cell.compare(f.Value) != 0 + case OpLt: + return cell.compare(f.Value) < 0 + case OpLe: + return cell.compare(f.Value) <= 0 + case OpGt: + return cell.compare(f.Value) > 0 + case OpGe: + return cell.compare(f.Value) >= 0 + default: // OpContains + return cell.Kind == KindString && f.Value.Kind == KindString && + strings.Contains(cell.Str, f.Value.Str) + } +} + +// sortRows stably orders rows by the sort keys in place (primary key first). A +// row lacking a key's field compares as the zero Value of no particular kind, so +// missing cells cluster consistently. With no keys it is a no-op. +func sortRows(rows []Record, keys []Sort) { + if len(keys) == 0 { + return + } + sort.SliceStable(rows, func(i, j int) bool { + for _, k := range keys { + c := rows[i][k.Field].compare(rows[j][k.Field]) + if c == 0 { + continue + } + if k.Desc { + return c > 0 + } + return c < 0 + } + return false + }) +} + +// group buckets rows by the value of field, preserving first-appearance order of +// the keys (rows are pre-sorted, so this is the query's order), and computes each +// group's aggregates. +func group(rows []Record, field string, aggs []Agg) []Group { + order := make([]Value, 0) + buckets := make(map[Value][]Record) + for _, r := range rows { + key := r[field] + if _, seen := buckets[key]; !seen { + order = append(order, key) + } + buckets[key] = append(buckets[key], r) + } + out := make([]Group, 0, len(order)) + for _, key := range order { + members := buckets[key] + out = append(out, Group{ + Key: key, + Rows: cloneRows(members), + Aggregates: aggregate(members, aggs), + }) + } + return out +} + +// aggregate computes every requested aggregate over rows. It returns nil when no +// aggregates are requested, so an aggregate-free View has a nil (not empty) map — +// a single canonical shape for "no aggregates". +func aggregate(rows []Record, aggs []Agg) map[string]Value { + if len(aggs) == 0 { + return nil + } + out := make(map[string]Value, len(aggs)) + for _, a := range aggs { + out[a.label()] = reduce(rows, a) + } + return out +} + +// reduce computes one aggregate. Count is an integer; sum/avg are floats; min/max +// carry the extreme value with its own kind. An empty input yields a zero-count, +// a zero sum/avg, and a zero-Value min/max. +func reduce(rows []Record, a Agg) Value { + if a.Func == AggCount { + return Int(int64(len(rows))) + } + var sum float64 + var n int + var ext Value + var have bool + for _, r := range rows { + cell, ok := r[a.Field] + if !ok { + continue + } + n++ + sum += cell.num() + if !have || (a.Func == AggMin && cell.compare(ext) < 0) || + (a.Func == AggMax && cell.compare(ext) > 0) { + ext, have = cell, true + } + } + switch a.Func { + case AggSum: + return Float(sum) + case AggAvg: + if n == 0 { + return Float(0) + } + return Float(sum / float64(n)) + default: // AggMin, AggMax + return ext + } +} + +// cloneRows deep-copies a slice of rows so a View never aliases a proxy's storage. +func cloneRows(rows []Record) []Record { + out := make([]Record, len(rows)) + for i, r := range rows { + out[i] = r.clone() + } + return out +} diff --git a/engine_test.go b/engine_test.go new file mode 100644 index 0000000..e499ac3 --- /dev/null +++ b/engine_test.go @@ -0,0 +1,194 @@ +// Copyright (c) 2026 the go-widgets/data authors. All rights reserved. +// Use of this source code is governed by a BSD-3-Clause license that can be +// found in the LICENSE file at the root of this repository. + +package data + +import "testing" + +// sample rows shared by the engine tests. +func sampleRows() []Record { + return []Record{ + rec(1, "ann", "red", 30, true), + rec(2, "bob", "blue", 20, false), + rec(3, "cara", "red", 50, true), + rec(4, "dan", "blue", 40, true), + rec(5, "eve", "red", 10, false), + } +} + +func TestApplyFilterOps(t *testing.T) { + rows := sampleRows() + tests := []struct { + name string + filter Filter + want int + }{ + {"eq", Filter{"team", OpEq, String("red")}, 3}, + {"ne", Filter{"team", OpNe, String("red")}, 2}, + {"lt", Filter{"salary", OpLt, Float(30)}, 2}, + {"le", Filter{"salary", OpLe, Float(30)}, 3}, + {"gt", Filter{"salary", OpGt, Float(30)}, 2}, + {"ge", Filter{"salary", OpGe, Float(30)}, 3}, + {"contains", Filter{"name", OpContains, String("a")}, 3}, // ann, cara, dan + {"contains-nonstring-cell", Filter{"id", OpContains, String("1")}, 0}, + {"missing-field", Filter{"ghost", OpEq, Int(1)}, 0}, + } + for _, tc := range tests { + v := Apply(rows, Query{Filters: []Filter{tc.filter}}) + if v.Total != tc.want { + t.Errorf("%s: Total = %d, want %d", tc.name, v.Total, tc.want) + } + } +} + +func TestApplyFilterAndedAndContainsEmpty(t *testing.T) { + rows := sampleRows() + v := Apply(rows, Query{Filters: []Filter{ + {"team", OpEq, String("red")}, + {"active", OpEq, Bool(true)}, + }}) + if v.Total != 2 { // ann, cara + t.Fatalf("ANDed = %d, want 2", v.Total) + } + // Empty substring matches every string cell. + if got := Apply(rows, Query{Filters: []Filter{{"name", OpContains, String("")}}}).Total; got != 5 { + t.Fatalf("empty contains = %d, want 5", got) + } +} + +func TestApplySortAscDescMultiAndMissing(t *testing.T) { + rows := sampleRows() + asc := Apply(rows, Query{Sorts: []Sort{{Field: "salary"}}}) + if asc.Rows[0]["name"] != String("eve") || asc.Rows[4]["name"] != String("cara") { + t.Fatalf("asc order wrong: %v .. %v", asc.Rows[0]["name"], asc.Rows[4]["name"]) + } + desc := Apply(rows, Query{Sorts: []Sort{{Field: "salary", Desc: true}}}) + if desc.Rows[0]["name"] != String("cara") { + t.Fatalf("desc order wrong: %v", desc.Rows[0]["name"]) + } + // Multi-key: team asc, then salary desc within team. + multi := Apply(rows, Query{Sorts: []Sort{{Field: "team"}, {Field: "salary", Desc: true}}}) + // blue: dan(40), bob(20); red: cara(50), ann(30), eve(10) + order := []string{"dan", "bob", "cara", "ann", "eve"} + for i, want := range order { + if multi.Rows[i]["name"] != String(want) { + t.Fatalf("multi[%d] = %v, want %s", i, multi.Rows[i]["name"], want) + } + } + // A sort on a field some rows lack must not panic and stays stable. + mixed := []Record{{"id": Int(1), "k": String("b")}, {"id": Int(2)}, {"id": Int(3), "k": String("a")}} + _ = Apply(mixed, Query{Sorts: []Sort{{Field: "k"}}}) +} + +func TestApplyPagingUngrouped(t *testing.T) { + rows := sampleRows() + q := Query{Sorts: []Sort{{Field: "id"}}, Offset: 1, Limit: 2} + v := Apply(rows, q) + if v.Total != 5 || len(v.Rows) != 2 || + v.Rows[0]["id"] != Int(2) || v.Rows[1]["id"] != Int(3) { + t.Fatalf("page = %+v", v.Rows) + } + // Offset past the end → empty page, Total intact. + if got := Apply(rows, Query{Offset: 99}); len(got.Rows) != 0 || got.Total != 5 { + t.Fatalf("over-offset = %+v", got) + } + // Negative offset clamps to 0; zero limit = all. + if got := Apply(rows, Query{Offset: -5}); len(got.Rows) != 5 { + t.Fatalf("neg offset = %d rows", len(got.Rows)) + } +} + +func TestApplyGroupingAndGroupPaging(t *testing.T) { + rows := sampleRows() + q := Query{ + Sorts: []Sort{{Field: "team"}, {Field: "id"}}, + GroupBy: "team", + Aggs: []Agg{{Func: AggCount}, {Field: "salary", Func: AggSum}}, + } + v := Apply(rows, q) + if v.Rows != nil { + t.Fatal("grouped view should have nil Rows") + } + if len(v.Groups) != 2 { + t.Fatalf("groups = %d, want 2", len(v.Groups)) + } + // blue first (sorted), count 2, sum 60. + blue := v.Groups[0] + if blue.Key != String("blue") || blue.Aggregates["count"] != Int(2) || + blue.Aggregates["sum(salary)"] != Float(60) { + t.Fatalf("blue group = %+v", blue) + } + // Grand aggregates over the whole filtered set. + if v.Aggregates["count"] != Int(5) || v.Aggregates["sum(salary)"] != Float(150) { + t.Fatalf("grand aggs = %+v", v.Aggregates) + } + // Paginate groups: Limit 1 → only the first group. + one := Apply(rows, Query{GroupBy: "team", Sorts: []Sort{{Field: "team"}}, Limit: 1}) + if len(one.Groups) != 1 || one.Groups[0].Key != String("blue") { + t.Fatalf("group page = %+v", one.Groups) + } +} + +func TestReduceAllFuncsAndEmpty(t *testing.T) { + rows := sampleRows() + all := Apply(rows, Query{Aggs: []Agg{ + {Func: AggCount}, + {Field: "salary", Func: AggSum}, + {Field: "salary", Func: AggAvg}, + {Field: "salary", Func: AggMin}, + {Field: "salary", Func: AggMax}, + }}) + a := all.Aggregates + if a["count"] != Int(5) || a["sum(salary)"] != Float(150) || a["avg(salary)"] != Float(30) || + a["min(salary)"] != Float(10) || a["max(salary)"] != Float(50) { + t.Fatalf("aggs = %+v", a) + } + // Empty set: count 0, sum/avg 0, min/max zero Value. + empty := Apply(nil, Query{Aggs: []Agg{ + {Func: AggCount}, {Field: "salary", Func: AggSum}, + {Field: "salary", Func: AggAvg}, {Field: "salary", Func: AggMin}, + }}) + e := empty.Aggregates + if e["count"] != Int(0) || e["sum(salary)"] != Float(0) || + e["avg(salary)"] != Float(0) || e["min(salary)"] != (Value{}) { + t.Fatalf("empty aggs = %+v", e) + } + // A reduce over rows that lack the field skips them (missing-cell branch). + noField := []Record{{"id": Int(1)}, {"id": Int(2)}} + got := Apply(noField, Query{Aggs: []Agg{{Field: "salary", Func: AggMax}}}) + if got.Aggregates["max(salary)"] != (Value{}) { + t.Fatalf("max over missing = %+v", got.Aggregates) + } + // No aggregates → nil map. + if Apply(rows, Query{}).Aggregates != nil { + t.Fatal("no-agg view should have nil Aggregates") + } +} + +func TestApplyDoesNotMutateOrAliasInput(t *testing.T) { + rows := sampleRows() + v := Apply(rows, Query{Sorts: []Sort{{Field: "salary"}}}) + // Mutating a returned row must not touch the source (cloneRows). + v.Rows[0]["name"] = String("changed") + for _, r := range rows { + if r["name"] == String("changed") { + t.Fatal("Apply aliased its input rows") + } + } +} + +func TestAggLabels(t *testing.T) { + cases := map[Agg]string{ + {Func: AggCount}: "count", + {Field: "p", Func: AggSum}: "sum(p)", + {Field: "p", Func: AggAvg}: "avg(p)", + {Field: "q", Func: AggMin}: "min(q)", + {Field: "q", Func: AggMax}: "max(q)", + } + for a, want := range cases { + if got := a.label(); got != want { + t.Errorf("label(%+v) = %q, want %q", a, got, want) + } + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..33fb90a --- /dev/null +++ b/go.mod @@ -0,0 +1,18 @@ +module github.com/go-widgets/data + +go 1.26.4 + +require ( + github.com/go-widgets/mvvm v0.5.0 + github.com/grpc-transports/websocket v0.0.0-20260807130344-e208e98ee231 + google.golang.org/grpc v1.80.0 + google.golang.org/protobuf v1.36.11 +) + +require ( + github.com/coder/websocket v1.8.15 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.33.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e0bb5b1 --- /dev/null +++ b/go.sum @@ -0,0 +1,44 @@ +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= +github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-widgets/mvvm v0.5.0 h1:o5hh6HAxbApONcbZxmyV9q12pCAPGRd6L24aS3gaCbA= +github.com/go-widgets/mvvm v0.5.0/go.mod h1:Phdrd434RLxXW1D6dL1PPQH1tABwYLIN2X7jQVC4TbY= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-transports/websocket v0.0.0-20260807130344-e208e98ee231 h1:6he/fkunjo+T15xNpUsywGv4lmAiGu2KVGOSvA8QEe4= +github.com/grpc-transports/websocket v0.0.0-20260807130344-e208e98ee231/go.mod h1:2z7Qy11qgPaGDfqzImOt0iyLwH0wjfsvaiidCIIpzgg= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 h1:sNrWoksmOyF5bvJUcnmbeAmQi8baNhqg5IWaI3llQqU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= diff --git a/grpcproxy/client.go b/grpcproxy/client.go new file mode 100644 index 0000000..799b1f1 --- /dev/null +++ b/grpcproxy/client.go @@ -0,0 +1,77 @@ +// Copyright (c) 2026 the go-widgets/data authors. All rights reserved. +// Use of this source code is governed by a BSD-3-Clause license that can be +// found in the LICENSE file at the root of this repository. + +package grpcproxy + +import ( + "context" + + "github.com/go-widgets/data" + "github.com/go-widgets/data/datapb" + wstransport "github.com/grpc-transports/websocket" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +// Client is a data.Proxy backed by a remote DataService reached over a +// WebSocket-carried gRPC connection. It satisfies the same interface a +// MemoryProxy does, so a Store cannot tell whether its data is local or remote — +// and because the WebSocket transport compiles to js/wasm, this exact Client +// runs in a browser talking to the native Server. +type Client struct { + cc *grpc.ClientConn + stub datapb.DataServiceClient +} + +// compile-time check that Client is a data.Proxy. +var _ data.Proxy = (*Client)(nil) + +// newGRPCClient is the grpc.NewClient seam, indirected so a test can force its +// error branch (a passthrough target never fails in practice). +var newGRPCClient = grpc.NewClient + +// Dial connects to a DataService at the given ws:// (or wss://) URL. Close the +// returned Client when done. +func Dial(url string) (*Client, error) { + dialOpt, err := wstransport.DialOption(url, wstransport.ClientConfig{}) + if err != nil { + return nil, err + } + cc, err := newGRPCClient("passthrough:///"+url, + dialOpt, + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + if err != nil { + return nil, err + } + return &Client{cc: cc, stub: datapb.NewDataServiceClient(cc)}, nil +} + +// Close releases the underlying connection. +func (c *Client) Close() error { return c.cc.Close() } + +// List returns every record from the remote service. +func (c *Client) List(ctx context.Context) ([]data.Record, error) { + resp, err := c.stub.List(ctx, &datapb.ListRequest{}) + if err != nil { + return nil, err + } + return recordsFromPB(resp.GetRecords()), nil +} + +// Query runs the query remotely and returns the View the server computed — the +// same View a MemoryProxy would have produced for the same rows. +func (c *Client) Query(ctx context.Context, q data.Query) (data.View, error) { + resp, err := c.stub.Query(ctx, queryToPB(q)) + if err != nil { + return data.View{}, err + } + return viewFromPB(resp), nil +} + +// Mutate applies one write remotely. +func (c *Client) Mutate(ctx context.Context, m data.Mutation) error { + _, err := c.stub.Mutate(ctx, mutationToPB(m)) + return err +} diff --git a/grpcproxy/conformance_test.go b/grpcproxy/conformance_test.go new file mode 100644 index 0000000..1823bcd --- /dev/null +++ b/grpcproxy/conformance_test.go @@ -0,0 +1,136 @@ +// Copyright (c) 2026 the go-widgets/data authors. All rights reserved. +// Use of this source code is governed by a BSD-3-Clause license that can be +// found in the LICENSE file at the root of this repository. + +package grpcproxy + +import ( + "bytes" + "context" + "testing" + + "github.com/go-widgets/data" +) + +// confSchema / confRows are the fixture the conformance battery runs against. +func confSchema() data.Schema { + return data.Schema{Fields: []data.Field{ + {Name: "id", Kind: data.KindInt}, + {Name: "name", Kind: data.KindString}, + {Name: "team", Kind: data.KindString}, + {Name: "salary", Kind: data.KindFloat}, + {Name: "active", Kind: data.KindBool}, + }} +} + +func confRow(id int64, name, team string, salary float64, active bool) data.Record { + return data.Record{ + "id": data.Int(id), "name": data.String(name), "team": data.String(team), + "salary": data.Float(salary), "active": data.Bool(active), + } +} + +func confRows() []data.Record { + return []data.Record{ + confRow(1, "ann", "red", 30, true), + confRow(2, "bob", "blue", 20, false), + confRow(3, "cara", "red", 50, true), + confRow(4, "dan", "blue", 40, true), + confRow(5, "eve", "red", 10, false), + confRow(6, "finn", "green", 40, true), + } +} + +// conformanceQueries is the battery of sort→filter→group→page→aggregate +// combinations the two proxies must agree on, byte for byte. +func conformanceQueries() map[string]data.Query { + allAggs := []data.Agg{ + {Func: data.AggCount}, + {Field: "salary", Func: data.AggSum}, + {Field: "salary", Func: data.AggAvg}, + {Field: "salary", Func: data.AggMin}, + {Field: "salary", Func: data.AggMax}, + } + return map[string]data.Query{ + "empty": {}, + "sort-asc": {Sorts: []data.Sort{{Field: "salary"}}}, + "sort-desc": {Sorts: []data.Sort{{Field: "salary", Desc: true}}}, + "sort-multi": {Sorts: []data.Sort{{Field: "team"}, {Field: "salary", Desc: true}}}, + "filter-team": {Filters: []data.Filter{{Field: "team", Op: data.OpEq, Value: data.String("red")}}}, + "filter-num": {Filters: []data.Filter{{Field: "salary", Op: data.OpGe, Value: data.Float(30)}}}, + "filter-contains": { + Filters: []data.Filter{{Field: "name", Op: data.OpContains, Value: data.String("a")}}, + Sorts: []data.Sort{{Field: "id"}}, + }, + "page": {Sorts: []data.Sort{{Field: "id"}}, Offset: 1, Limit: 3}, + "filter-sort-page": { + Filters: []data.Filter{{Field: "active", Op: data.OpEq, Value: data.Bool(true)}}, + Sorts: []data.Sort{{Field: "salary", Desc: true}}, + Offset: 1, Limit: 2, + }, + "group": { + Sorts: []data.Sort{{Field: "team"}, {Field: "id"}}, GroupBy: "team", Aggs: allAggs, + }, + "group-page": { + Sorts: []data.Sort{{Field: "team"}}, GroupBy: "team", Offset: 1, Limit: 1, Aggs: allAggs, + }, + "full-pipeline": { + Filters: []data.Filter{{Field: "salary", Op: data.OpGe, Value: data.Float(20)}}, + Sorts: []data.Sort{{Field: "team"}, {Field: "salary", Desc: true}}, + GroupBy: "team", Aggs: allAggs, + }, + "grand-aggs-only": {Aggs: allAggs}, + "empty-result": {Filters: []data.Filter{{Field: "team", Op: data.OpEq, Value: data.String("nope")}}, Aggs: allAggs}, + "empty-group": {GroupBy: "team", Filters: []data.Filter{{Field: "id", Op: data.OpEq, Value: data.Int(999)}}, Aggs: allAggs}, + } +} + +// TestProxyConformance is the crown-jewel assertion: for every query in the +// battery, the in-process MemoryProxy and the remote gRPC Client must return +// Views that canonicalise to identical bytes — and both must equal what the +// shared engine computes directly. If they ever diverge, the same data code +// would render differently native vs in a browser, and this fails loudly. +func TestProxyConformance(t *testing.T) { + ctx := context.Background() + mem, err := data.NewMemoryProxy(confSchema(), "id", confRows()...) + if err != nil { + t.Fatalf("memory proxy: %v", err) + } + url, stop, err := Serve(":0", mem) + if err != nil { + t.Fatalf("serve: %v", err) + } + defer stop() + client, err := Dial(url) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer client.Close() + + queries := conformanceQueries() + total := 0 + for name, q := range queries { + local, err := mem.Query(ctx, q) + if err != nil { + t.Fatalf("%s: local query: %v", name, err) + } + remote, err := client.Query(ctx, q) + if err != nil { + t.Fatalf("%s: remote query: %v", name, err) + } + reference := data.Apply(confRows(), q) + + cl := data.Canonical(local) + cr := data.Canonical(remote) + cref := data.Canonical(reference) + if !bytes.Equal(cl, cr) { + t.Fatalf("%s: MemoryProxy vs GRPCProxy DIVERGE:\n--- memory ---\n%s\n--- grpc ---\n%s", + name, cl, cr) + } + if !bytes.Equal(cl, cref) { + t.Fatalf("%s: proxy vs reference engine diverge:\n%s\n---\n%s", name, cl, cref) + } + total++ + } + t.Logf("proxy conformance: %d queries, MemoryProxy == GRPCProxy == engine (byte-identical)", total) +} diff --git a/grpcproxy/conv.go b/grpcproxy/conv.go new file mode 100644 index 0000000..90a3d5c --- /dev/null +++ b/grpcproxy/conv.go @@ -0,0 +1,197 @@ +// Copyright (c) 2026 the go-widgets/data authors. All rights reserved. +// Use of this source code is governed by a BSD-3-Clause license that can be +// found in the LICENSE file at the root of this repository. + +// Package grpcproxy carries the data spine over gRPC: a Server that serves any +// data.Proxy, and a Client (itself a data.Proxy) that reaches it. The transport +// is grpc-transports/websocket, so the exact same query pipeline runs whether a +// Store talks to a MemoryProxy in-process or to this Client from a browser/wasm +// build — and the Views come back byte-identical. +package grpcproxy + +import ( + "github.com/go-widgets/data" + "github.com/go-widgets/data/datapb" +) + +// The data.* scalar enums are declared in the same order as their protobuf +// counterparts, so each converts by a plain numeric cast. These helpers keep the +// casts in one place and self-documenting. + +func valueToPB(v data.Value) *datapb.Value { + return &datapb.Value{ + Kind: datapb.Kind(v.Kind), + Str: v.Str, + Int: v.Int, + Float: v.Float, + Bool: v.Bool, + } +} + +func valueFromPB(v *datapb.Value) data.Value { + if v == nil { + return data.Value{} + } + return data.Value{ + Kind: data.Kind(v.GetKind()), + Str: v.GetStr(), + Int: v.GetInt(), + Float: v.GetFloat(), + Bool: v.GetBool(), + } +} + +func recordToPB(r data.Record) *datapb.Record { + fields := make(map[string]*datapb.Value, len(r)) + for k, v := range r { + fields[k] = valueToPB(v) + } + return &datapb.Record{Fields: fields} +} + +func recordFromPB(r *datapb.Record) data.Record { + if r == nil { + return nil + } + out := make(data.Record, len(r.GetFields())) + for k, v := range r.GetFields() { + out[k] = valueFromPB(v) + } + return out +} + +func recordsToPB(rows []data.Record) []*datapb.Record { + out := make([]*datapb.Record, len(rows)) + for i, r := range rows { + out[i] = recordToPB(r) + } + return out +} + +func recordsFromPB(rows []*datapb.Record) []data.Record { + out := make([]data.Record, len(rows)) + for i, r := range rows { + out[i] = recordFromPB(r) + } + return out +} + +func aggsToPB(m map[string]data.Value) map[string]*datapb.Value { + if m == nil { + return nil + } + out := make(map[string]*datapb.Value, len(m)) + for k, v := range m { + out[k] = valueToPB(v) + } + return out +} + +func aggsFromPB(m map[string]*datapb.Value) map[string]data.Value { + if len(m) == 0 { + return nil + } + out := make(map[string]data.Value, len(m)) + for k, v := range m { + out[k] = valueFromPB(v) + } + return out +} + +func queryToPB(q data.Query) *datapb.Query { + filters := make([]*datapb.Filter, len(q.Filters)) + for i, f := range q.Filters { + filters[i] = &datapb.Filter{Field: f.Field, Op: datapb.FilterOp(f.Op), Value: valueToPB(f.Value)} + } + sorts := make([]*datapb.Sort, len(q.Sorts)) + for i, s := range q.Sorts { + sorts[i] = &datapb.Sort{Field: s.Field, Desc: s.Desc} + } + aggs := make([]*datapb.Agg, len(q.Aggs)) + for i, a := range q.Aggs { + aggs[i] = &datapb.Agg{Field: a.Field, Func: datapb.AggFunc(a.Func)} + } + return &datapb.Query{ + Filters: filters, Sorts: sorts, GroupBy: q.GroupBy, + Offset: int32(q.Offset), Limit: int32(q.Limit), Aggs: aggs, + } +} + +func queryFromPB(q *datapb.Query) data.Query { + filters := make([]data.Filter, len(q.GetFilters())) + for i, f := range q.GetFilters() { + filters[i] = data.Filter{Field: f.GetField(), Op: data.FilterOp(f.GetOp()), Value: valueFromPB(f.GetValue())} + } + sorts := make([]data.Sort, len(q.GetSorts())) + for i, s := range q.GetSorts() { + sorts[i] = data.Sort{Field: s.GetField(), Desc: s.GetDesc()} + } + aggs := make([]data.Agg, len(q.GetAggs())) + for i, a := range q.GetAggs() { + aggs[i] = data.Agg{Field: a.GetField(), Func: data.AggFunc(a.GetFunc())} + } + return data.Query{ + Filters: filters, Sorts: sorts, GroupBy: q.GetGroupBy(), + Offset: int(q.GetOffset()), Limit: int(q.GetLimit()), Aggs: aggs, + } +} + +func viewToPB(v data.View) *datapb.View { + out := &datapb.View{ + Total: int32(v.Total), + Aggregates: aggsToPB(v.Aggregates), + Grouped: v.Groups != nil, + } + if v.Groups != nil { + out.Groups = make([]*datapb.Group, len(v.Groups)) + for i, g := range v.Groups { + out.Groups[i] = &datapb.Group{ + Key: valueToPB(g.Key), + Rows: recordsToPB(g.Rows), + Aggregates: aggsToPB(g.Aggregates), + } + } + return out + } + out.Rows = recordsToPB(v.Rows) + return out +} + +func viewFromPB(v *datapb.View) data.View { + out := data.View{ + Total: int(v.GetTotal()), + Aggregates: aggsFromPB(v.GetAggregates()), + } + // The grouped flag is what preserves the nil-Rows / non-nil-Groups shape a + // grouped View has, which repeated fields alone cannot express. + if v.GetGrouped() { + groups := make([]data.Group, len(v.GetGroups())) + for i, g := range v.GetGroups() { + groups[i] = data.Group{ + Key: valueFromPB(g.GetKey()), + Rows: recordsFromPB(g.GetRows()), + Aggregates: aggsFromPB(g.GetAggregates()), + } + } + out.Groups = groups + return out + } + out.Rows = recordsFromPB(v.GetRows()) + return out +} + +func mutationToPB(m data.Mutation) *datapb.Mutation { + out := &datapb.Mutation{Kind: datapb.MutationKind(m.Kind), Key: valueToPB(m.Key)} + if m.Record != nil { + out.Record = recordToPB(m.Record) + } + return out +} + +func mutationFromPB(m *datapb.Mutation) data.Mutation { + return data.Mutation{ + Kind: data.MutationKind(m.GetKind()), + Record: recordFromPB(m.GetRecord()), + Key: valueFromPB(m.GetKey()), + } +} diff --git a/grpcproxy/grpcproxy_test.go b/grpcproxy/grpcproxy_test.go new file mode 100644 index 0000000..e4ed88b --- /dev/null +++ b/grpcproxy/grpcproxy_test.go @@ -0,0 +1,185 @@ +// Copyright (c) 2026 the go-widgets/data authors. All rights reserved. +// Use of this source code is governed by a BSD-3-Clause license that can be +// found in the LICENSE file at the root of this repository. + +package grpcproxy + +import ( + "bytes" + "context" + "errors" + "testing" + + "github.com/go-widgets/data" + "github.com/go-widgets/data/datapb" + "google.golang.org/grpc" +) + +// serveMemory starts a Server over a fresh MemoryProxy seeded with confRows and +// returns a connected Client, cleaning both up via t.Cleanup. +func serveMemory(t *testing.T) (*data.MemoryProxy, *Client) { + t.Helper() + mem, err := data.NewMemoryProxy(confSchema(), "id", confRows()...) + if err != nil { + t.Fatalf("memory: %v", err) + } + url, stop, err := Serve(":0", mem) + if err != nil { + t.Fatalf("serve: %v", err) + } + t.Cleanup(stop) + client, err := Dial(url) + if err != nil { + t.Fatalf("dial: %v", err) + } + t.Cleanup(func() { _ = client.Close() }) + return mem, client +} + +func TestListParity(t *testing.T) { + mem, client := serveMemory(t) + ctx := context.Background() + + local, err := mem.List(ctx) + if err != nil { + t.Fatalf("local list: %v", err) + } + remote, err := client.List(ctx) + if err != nil { + t.Fatalf("remote list: %v", err) + } + // List order is the storage order on both sides; compare canonically per row. + if len(local) != len(remote) { + t.Fatalf("len local %d != remote %d", len(local), len(remote)) + } + for i := range local { + lv := data.Canonical(data.View{Rows: []data.Record{local[i]}, Total: 1}) + rv := data.Canonical(data.View{Rows: []data.Record{remote[i]}, Total: 1}) + if !bytes.Equal(lv, rv) { + t.Fatalf("row %d differs:\n%s\n%s", i, lv, rv) + } + } +} + +func TestMutateThroughClientReflectsOnServer(t *testing.T) { + mem, client := serveMemory(t) + ctx := context.Background() + + // Insert via the client. + if err := client.Mutate(ctx, data.Mutation{ + Kind: data.MutInsert, + Record: confRow(7, "gwen", "green", 15, true), + }); err != nil { + t.Fatalf("client insert: %v", err) + } + if list, _ := mem.List(ctx); len(list) != 7 { + t.Fatalf("server has %d rows after insert, want 7", len(list)) + } + + // Update via the client. + if err := client.Mutate(ctx, data.Mutation{ + Kind: data.MutUpdate, + Record: confRow(7, "gwenn", "green", 16, true), + }); err != nil { + t.Fatalf("client update: %v", err) + } + v, _ := mem.Query(ctx, data.Query{Filters: []data.Filter{{Field: "id", Op: data.OpEq, Value: data.Int(7)}}}) + if v.Rows[0]["name"] != data.String("gwenn") { + t.Fatalf("update not reflected: %+v", v.Rows[0]) + } + + // Delete via the client (carries only the key — exercises nil-Record mutation). + if err := client.Mutate(ctx, data.Mutation{Kind: data.MutDelete, Key: data.Int(7)}); err != nil { + t.Fatalf("client delete: %v", err) + } + if list, _ := mem.List(ctx); len(list) != 6 { + t.Fatalf("server has %d rows after delete, want 6", len(list)) + } + + // A duplicate insert surfaces the server-side error to the client. + if err := client.Mutate(ctx, data.Mutation{Kind: data.MutInsert, Record: confRow(1, "dup", "red", 1, true)}); err == nil { + t.Fatal("expected duplicate-key error through the client") + } +} + +// failProxy makes every operation fail, to cover the Server error branches. +type failProxy struct{ err error } + +func (f failProxy) List(context.Context) ([]data.Record, error) { return nil, f.err } +func (f failProxy) Query(context.Context, data.Query) (data.View, error) { + return data.View{}, f.err +} +func (f failProxy) Mutate(context.Context, data.Mutation) error { return f.err } + +func TestServerErrorsPropagate(t *testing.T) { + url, stop, err := Serve(":0", failProxy{err: errors.New("backend down")}) + if err != nil { + t.Fatalf("serve: %v", err) + } + defer stop() + client, err := Dial(url) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer client.Close() + ctx := context.Background() + + if _, err := client.List(ctx); err == nil { + t.Fatal("List should surface the backend error") + } + if _, err := client.Query(ctx, data.Query{}); err == nil { + t.Fatal("Query should surface the backend error") + } + if err := client.Mutate(ctx, data.Mutation{Kind: data.MutInsert, Record: confRow(1, "a", "b", 1, true)}); err == nil { + t.Fatal("Mutate should surface the backend error") + } +} + +func TestDialErrors(t *testing.T) { + // Empty URL fails in wstransport.DialOption. + if _, err := Dial(""); err == nil { + t.Fatal("Dial(\"\") should fail") + } + // grpc.NewClient failure via the seam. + orig := newGRPCClient + newGRPCClient = func(string, ...grpc.DialOption) (*grpc.ClientConn, error) { + return nil, errors.New("newclient boom") + } + defer func() { newGRPCClient = orig }() + if _, err := Dial("ws://127.0.0.1:1/"); err == nil { + t.Fatal("Dial should fail when grpc.NewClient errors") + } +} + +// TestServeBadAddress covers the Serve listen-error branch. +func TestServeBadAddress(t *testing.T) { + if _, _, err := Serve("256.256.256.256:99999", failProxy{}); err == nil { + t.Fatal("Serve should fail to bind a bad address") + } +} + +// TestConversionNilBranches covers the nil-guard branches in conv.go that the +// happy-path round trips do not reach on their own. +func TestConversionNilBranches(t *testing.T) { + if v := valueFromPB(nil); v != (data.Value{}) { + t.Fatalf("valueFromPB(nil) = %+v", v) + } + if r := recordFromPB(nil); r != nil { + t.Fatalf("recordFromPB(nil) = %+v", r) + } + if m := aggsFromPB(nil); m != nil { + t.Fatal("aggsFromPB(nil) should be nil") + } + if m := aggsToPB(nil); m != nil { + t.Fatal("aggsToPB(nil) should be nil") + } + // A view carrying an empty (non-nil) aggregate map still round-trips to nil, + // and an ungrouped empty view stays ungrouped. + pb := viewToPB(data.View{Rows: []data.Record{}, Total: 0}) + if pb.GetGrouped() { + t.Fatal("ungrouped view marked grouped") + } + if got := viewFromPB(&datapb.View{Grouped: false}); got.Groups != nil { + t.Fatalf("ungrouped decode has groups: %+v", got) + } +} diff --git a/grpcproxy/server.go b/grpcproxy/server.go new file mode 100644 index 0000000..69aad88 --- /dev/null +++ b/grpcproxy/server.go @@ -0,0 +1,79 @@ +// Copyright (c) 2026 the go-widgets/data authors. All rights reserved. +// Use of this source code is governed by a BSD-3-Clause license that can be +// found in the LICENSE file at the root of this repository. + +//go:build !js + +package grpcproxy + +import ( + "context" + "fmt" + + "github.com/go-widgets/data" + "github.com/go-widgets/data/datapb" + wstransport "github.com/grpc-transports/websocket" + "google.golang.org/grpc" +) + +// Server adapts any data.Proxy to the DataService gRPC contract: each RPC simply +// delegates to the wrapped proxy (a MemoryProxy in practice), so the query +// pipeline that produces a View runs server-side and the client receives exactly +// that View. It is native-only — a browser is the client, never the server. +type Server struct { + datapb.UnimplementedDataServiceServer + proxy data.Proxy +} + +// NewServer wraps proxy as a DataService implementation. +func NewServer(proxy data.Proxy) *Server { return &Server{proxy: proxy} } + +// List returns every record. +func (s *Server) List(ctx context.Context, _ *datapb.ListRequest) (*datapb.ListResponse, error) { + rows, err := s.proxy.List(ctx) + if err != nil { + return nil, err + } + return &datapb.ListResponse{Records: recordsToPB(rows)}, nil +} + +// Query applies the query on the proxy and returns the resulting View. +func (s *Server) Query(ctx context.Context, q *datapb.Query) (*datapb.View, error) { + view, err := s.proxy.Query(ctx, queryFromPB(q)) + if err != nil { + return nil, err + } + return viewToPB(view), nil +} + +// Mutate applies one write. +func (s *Server) Mutate(ctx context.Context, m *datapb.Mutation) (*datapb.MutateResponse, error) { + if err := s.proxy.Mutate(ctx, mutationFromPB(m)); err != nil { + return nil, err + } + return &datapb.MutateResponse{}, nil +} + +// RegisterServer registers a DataService backed by proxy onto an existing +// grpc.Server — use it when mounting the service alongside others. +func RegisterServer(gs *grpc.Server, proxy data.Proxy) { + datapb.RegisterDataServiceServer(gs, NewServer(proxy)) +} + +// Serve starts a DataService for proxy over a WebSocket-carried gRPC listener +// bound to addr (use ":0" for an ephemeral port). It returns the ws:// URL a +// Client dials and a stop function that shuts the server down. The WebSocket +// carrier is what lets a browser/wasm Client speak this same gRPC service. +func Serve(addr string, proxy data.Proxy) (url string, stop func(), err error) { + lis, err := wstransport.ListenWebSocket(addr, wstransport.ServerConfig{ + OriginPatterns: []string{"*"}, + }) + if err != nil { + return "", nil, err + } + gs := grpc.NewServer() + RegisterServer(gs, proxy) + go func() { _ = gs.Serve(lis) }() + url = fmt.Sprintf("ws://%s/", lis.Addr().String()) + return url, func() { gs.Stop() }, nil +} diff --git a/memory.go b/memory.go new file mode 100644 index 0000000..5e79951 --- /dev/null +++ b/memory.go @@ -0,0 +1,114 @@ +// Copyright (c) 2026 the go-widgets/data authors. All rights reserved. +// Use of this source code is governed by a BSD-3-Clause license that can be +// found in the LICENSE file at the root of this repository. + +package data + +import ( + "context" + "errors" + "fmt" + "sync" +) + +// ErrKeyExists / ErrNotFound report the two identity failures a mutation can +// hit: inserting a key that already exists, or updating/deleting one that does +// not. +var ( + ErrKeyExists = errors.New("data: key already exists") + ErrNotFound = errors.New("data: record not found") +) + +// MemoryProxy is an in-process Proxy over a slice of validated records. It is +// the reference backend: its Query runs the shared Apply engine directly, and a +// grpcproxy Server wraps one to serve the same results across the wire. +// +// It is safe for concurrent use. +type MemoryProxy struct { + schema Schema + key string + mu sync.RWMutex + rows []Record +} + +// NewMemoryProxy builds a MemoryProxy for the given schema, using keyField as +// the record identity (it must be a declared field). Each seed record is +// validated and must have a unique key. It returns an error on an unknown key +// field, an invalid seed, or a duplicate key. +func NewMemoryProxy(schema Schema, keyField string, seed ...Record) (*MemoryProxy, error) { + if _, ok := schema.Field(keyField); !ok { + return nil, fmt.Errorf("data: key field %q is not in the schema", keyField) + } + m := &MemoryProxy{schema: schema, key: keyField} + for _, r := range seed { + if err := m.insert(r); err != nil { + return nil, err + } + } + return m, nil +} + +// indexOf returns the position of the row whose key field equals key, or -1. +// Caller holds at least a read lock. +func (m *MemoryProxy) indexOf(key Value) int { + for i, r := range m.rows { + if r[m.key] == key { + return i + } + } + return -1 +} + +// insert validates r and appends it, rejecting a duplicate key. Caller holds the +// write lock (or is the constructor, which is not yet shared). +func (m *MemoryProxy) insert(r Record) error { + if err := m.schema.Validate(r); err != nil { + return err + } + if m.indexOf(r[m.key]) >= 0 { + return ErrKeyExists + } + m.rows = append(m.rows, r.clone()) + return nil +} + +// List returns a deep copy of every record. +func (m *MemoryProxy) List(_ context.Context) ([]Record, error) { + m.mu.RLock() + defer m.mu.RUnlock() + return cloneRows(m.rows), nil +} + +// Query applies q via the shared engine over a snapshot of the records. +func (m *MemoryProxy) Query(_ context.Context, q Query) (View, error) { + m.mu.RLock() + defer m.mu.RUnlock() + return Apply(m.rows, q), nil +} + +// Mutate applies one write under the write lock. +func (m *MemoryProxy) Mutate(_ context.Context, mut Mutation) error { + m.mu.Lock() + defer m.mu.Unlock() + switch mut.Kind { + case MutInsert: + return m.insert(mut.Record) + case MutUpdate: + if err := m.schema.Validate(mut.Record); err != nil { + return err + } + i := m.indexOf(mut.Record[m.key]) + if i < 0 { + return ErrNotFound + } + m.rows[i] = mut.Record.clone() + return nil + default: // MutDelete + i := m.indexOf(mut.Key) + if i < 0 { + return ErrNotFound + } + m.rows = append(m.rows[:i], m.rows[i+1:]...) + return nil + } +} diff --git a/memory_test.go b/memory_test.go new file mode 100644 index 0000000..3e0d937 --- /dev/null +++ b/memory_test.go @@ -0,0 +1,106 @@ +// Copyright (c) 2026 the go-widgets/data authors. All rights reserved. +// Use of this source code is governed by a BSD-3-Clause license that can be +// found in the LICENSE file at the root of this repository. + +package data + +import ( + "context" + "errors" + "testing" +) + +func mustMemory(t *testing.T, seed ...Record) *MemoryProxy { + t.Helper() + m, err := NewMemoryProxy(testSchema(), "id", seed...) + if err != nil { + t.Fatalf("NewMemoryProxy: %v", err) + } + return m +} + +func TestNewMemoryProxyGuards(t *testing.T) { + // Unknown key field. + if _, err := NewMemoryProxy(testSchema(), "nope"); err == nil { + t.Fatal("expected unknown-key error") + } + // Invalid seed (Required name empty). + if _, err := NewMemoryProxy(testSchema(), "id", rec(1, "", "x", 1, true)); err == nil { + t.Fatal("expected invalid-seed error") + } + // Duplicate seed key. + _, err := NewMemoryProxy(testSchema(), "id", + rec(1, "a", "x", 1, true), rec(1, "b", "y", 2, true)) + if !errors.Is(err, ErrKeyExists) { + t.Fatalf("dup seed err = %v", err) + } +} + +func TestMemoryListAndQuery(t *testing.T) { + m := mustMemory(t, sampleRows()...) + ctx := context.Background() + + list, err := m.List(ctx) + if err != nil || len(list) != 5 { + t.Fatalf("List = %d rows, err %v", len(list), err) + } + // List returns copies: mutating one must not affect a later List. + list[0]["name"] = String("hax") + again, _ := m.List(ctx) + if again[0]["name"] == String("hax") { + t.Fatal("List aliased internal storage") + } + + v, err := m.Query(ctx, Query{Filters: []Filter{{"team", OpEq, String("red")}}}) + if err != nil || v.Total != 3 { + t.Fatalf("Query = %+v err %v", v.Total, err) + } +} + +func TestMemoryMutate(t *testing.T) { + m := mustMemory(t, rec(1, "a", "x", 1, true)) + ctx := context.Background() + + // Insert new. + if err := m.Mutate(ctx, Mutation{Kind: MutInsert, Record: rec(2, "b", "y", 2, true)}); err != nil { + t.Fatalf("insert: %v", err) + } + // Insert duplicate key → ErrKeyExists. + if err := m.Mutate(ctx, Mutation{Kind: MutInsert, Record: rec(2, "c", "z", 3, true)}); !errors.Is(err, ErrKeyExists) { + t.Fatalf("dup insert err = %v", err) + } + // Insert invalid → validation error. + if err := m.Mutate(ctx, Mutation{Kind: MutInsert, Record: rec(9, "", "z", 3, true)}); err == nil { + t.Fatal("invalid insert should fail") + } + + // Update existing. + if err := m.Mutate(ctx, Mutation{Kind: MutUpdate, Record: rec(2, "bee", "y", 22, true)}); err != nil { + t.Fatalf("update: %v", err) + } + v, _ := m.Query(ctx, Query{Filters: []Filter{{"id", OpEq, Int(2)}}}) + if v.Rows[0]["name"] != String("bee") || v.Rows[0]["salary"] != Float(22) { + t.Fatalf("update not applied: %+v", v.Rows[0]) + } + // Update missing key → ErrNotFound. + if err := m.Mutate(ctx, Mutation{Kind: MutUpdate, Record: rec(99, "x", "y", 1, true)}); !errors.Is(err, ErrNotFound) { + t.Fatalf("update missing err = %v", err) + } + // Update invalid → validation error (before the not-found check). + if err := m.Mutate(ctx, Mutation{Kind: MutUpdate, Record: rec(2, "", "y", 1, true)}); err == nil { + t.Fatal("invalid update should fail") + } + + // Delete existing. + if err := m.Mutate(ctx, Mutation{Kind: MutDelete, Key: Int(1)}); err != nil { + t.Fatalf("delete: %v", err) + } + // Delete missing → ErrNotFound. + if err := m.Mutate(ctx, Mutation{Kind: MutDelete, Key: Int(1)}); !errors.Is(err, ErrNotFound) { + t.Fatalf("delete missing err = %v", err) + } + list, _ := m.List(ctx) + if len(list) != 1 || list[0]["id"] != Int(2) { + t.Fatalf("after deletes = %+v", list) + } +} diff --git a/proto/data.proto b/proto/data.proto new file mode 100644 index 0000000..68239e3 --- /dev/null +++ b/proto/data.proto @@ -0,0 +1,127 @@ +// Copyright (c) 2026 the go-widgets/data authors. All rights reserved. +// Use of this source code is governed by a BSD-3-Clause license that can be +// found in the LICENSE file at the root of this repository. + +syntax = "proto3"; + +package datapb; + +option go_package = "github.com/go-widgets/data/datapb"; + +// Kind mirrors data.Kind: the scalar type of a Value. +enum Kind { + KIND_STRING = 0; + KIND_INT = 1; + KIND_FLOAT = 2; + KIND_BOOL = 3; +} + +// Value mirrors data.Value: a tagged scalar. Only the field named by kind is +// meaningful. +message Value { + Kind kind = 1; + string str = 2; + int64 int = 3; + double float = 4; + bool bool = 5; +} + +// Record mirrors data.Record: a row of named cells. +message Record { + map fields = 1; +} + +// FilterOp mirrors data.FilterOp. +enum FilterOp { + OP_EQ = 0; + OP_NE = 1; + OP_LT = 2; + OP_LE = 3; + OP_GT = 4; + OP_GE = 5; + OP_CONTAINS = 6; +} + +message Filter { + string field = 1; + FilterOp op = 2; + Value value = 3; +} + +message Sort { + string field = 1; + bool desc = 2; +} + +// AggFunc mirrors data.AggFunc. +enum AggFunc { + AGG_COUNT = 0; + AGG_SUM = 1; + AGG_AVG = 2; + AGG_MIN = 3; + AGG_MAX = 4; +} + +message Agg { + string field = 1; + AggFunc func = 2; +} + +// Query mirrors data.Query: filter, sort, group, page, aggregate. +message Query { + repeated Filter filters = 1; + repeated Sort sorts = 2; + string group_by = 3; + int32 offset = 4; + int32 limit = 5; + repeated Agg aggs = 6; +} + +// Group mirrors data.Group. +message Group { + Value key = 1; + repeated Record rows = 2; + map aggregates = 3; +} + +// View mirrors data.View. The grouped flag distinguishes a grouped view (whose +// Rows is nil and Groups non-nil) from an ungrouped one, which repeated fields +// alone cannot carry across the wire. +message View { + repeated Record rows = 1; + repeated Group groups = 2; + int32 total = 3; + map aggregates = 4; + bool grouped = 5; +} + +// MutationKind mirrors data.MutationKind. +enum MutationKind { + MUT_INSERT = 0; + MUT_UPDATE = 1; + MUT_DELETE = 2; +} + +message Mutation { + MutationKind kind = 1; + Record record = 2; + Value key = 3; +} + +message ListRequest {} + +message ListResponse { + repeated Record records = 1; +} + +message MutateResponse {} + +// DataService is the small list/query/mutate contract a grpcproxy.Client speaks +// to a grpcproxy.Server. The Server delegates each call to any data.Proxy (a +// MemoryProxy in practice), so the same query pipeline runs server-side and the +// client gets back the identical View. +service DataService { + rpc List(.datapb.ListRequest) returns (.datapb.ListResponse); + rpc Query(.datapb.Query) returns (.datapb.View); + rpc Mutate(.datapb.Mutation) returns (.datapb.MutateResponse); +} diff --git a/proto/generate.sh b/proto/generate.sh new file mode 100755 index 0000000..d61afda --- /dev/null +++ b/proto/generate.sh @@ -0,0 +1,12 @@ +#!/bin/sh +# Regenerate datapb/*.pb.go from proto/data.proto. Requires protoc plus +# protoc-gen-go (v1.36.x) and protoc-gen-go-grpc (v1.5.x) on PATH: +# go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.11 +# go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.5.1 +set -e +cd "$(dirname "$0")/.." +protoc \ + --go_out=. --go_opt=module=github.com/go-widgets/data \ + --go-grpc_out=. --go-grpc_opt=module=github.com/go-widgets/data \ + proto/data.proto +echo "regenerated datapb/" diff --git a/proxy.go b/proxy.go new file mode 100644 index 0000000..dce9df7 --- /dev/null +++ b/proxy.go @@ -0,0 +1,42 @@ +// Copyright (c) 2026 the go-widgets/data authors. All rights reserved. +// Use of this source code is governed by a BSD-3-Clause license that can be +// found in the LICENSE file at the root of this repository. + +package data + +import "context" + +// Proxy is the pluggable data backend a Store talks to. The same three +// operations are served in-process by MemoryProxy and remotely by +// grpcproxy.Client, so a Store — and every sort/filter/group/page/aggregate it +// drives — is oblivious to whether the data is local or across a websocket. +type Proxy interface { + // List returns every record, unfiltered and unordered (a fresh copy each + // call, so the caller can retain it safely). + List(ctx context.Context) ([]Record, error) + // Query applies q to the backend's records and returns the resulting View. + Query(ctx context.Context, q Query) (View, error) + // Mutate inserts, updates or deletes one record and reports any validation + // or not-found error. + Mutate(ctx context.Context, m Mutation) error +} + +// MutationKind is the operation a Mutation performs. +type MutationKind uint8 + +const ( + // MutInsert adds Record (which must be new under the key field). + MutInsert MutationKind = iota + // MutUpdate replaces the record whose key field equals Record's key field. + MutUpdate + // MutDelete removes the record whose key field equals Key. + MutDelete +) + +// Mutation is a single write. Insert and Update carry the full Record; Delete +// carries only the Key (the value of the backend's key field to remove). +type Mutation struct { + Kind MutationKind + Record Record + Key Value +} diff --git a/query.go b/query.go new file mode 100644 index 0000000..77d6e15 --- /dev/null +++ b/query.go @@ -0,0 +1,135 @@ +// Copyright (c) 2026 the go-widgets/data authors. All rights reserved. +// Use of this source code is governed by a BSD-3-Clause license that can be +// found in the LICENSE file at the root of this repository. + +package data + +// FilterOp is a comparison a Filter applies between a record's cell and a +// reference Value. +type FilterOp uint8 + +const ( + // OpEq keeps rows whose cell equals the reference value. + OpEq FilterOp = iota + // OpNe keeps rows whose cell differs from the reference value. + OpNe + // OpLt / OpLe / OpGt / OpGe keep rows ordered below / at-or-below / above / + // at-or-above the reference value (Value.compare ordering). + OpLt + OpLe + OpGt + OpGe + // OpContains keeps rows whose string cell contains the reference substring. + // It only matches string cells (a non-string cell never contains). + OpContains +) + +// Filter is one predicate: keep the rows for which Field's cell relates to Value +// under Op. A filter on a field a row lacks never matches. +type Filter struct { + Field string + Op FilterOp + Value Value +} + +// Sort is one ordering key: order by Field ascending, or descending when Desc. +// A Query's Sorts apply in order, the first being the primary key. +type Sort struct { + Field string + Desc bool +} + +// AggFunc is a column aggregation. +type AggFunc uint8 + +const ( + // AggCount counts rows (its Field is ignored). + AggCount AggFunc = iota + // AggSum / AggAvg / AggMin / AggMax reduce a numeric column. + AggSum + AggAvg + AggMin + AggMax +) + +// Agg requests one aggregate over a column. For AggCount the Field is ignored. +type Agg struct { + Field string + Func AggFunc +} + +// label is the stable key an aggregate is stored under in a View — "count", +// "sum(price)", "avg(price)", "min(qty)", "max(qty)" — so the aggregates map is +// deterministic and self-describing. +func (a Agg) label() string { + switch a.Func { + case AggCount: + return "count" + case AggSum: + return "sum(" + a.Field + ")" + case AggAvg: + return "avg(" + a.Field + ")" + case AggMin: + return "min(" + a.Field + ")" + default: // AggMax + return "max(" + a.Field + ")" + } +} + +// Query is a full read specification the engine applies to a set of records: +// keep the rows matching every Filter, order them by Sorts, optionally group by +// a field, take a page (Offset/Limit), and compute Aggs. Zero-value fields are +// inert — an empty Query returns every row unchanged with no grouping or paging. +type Query struct { + // Filters are ANDed: a row must satisfy all of them. + Filters []Filter + // Sorts order the surviving rows (primary key first). + Sorts []Sort + // GroupBy, when non-empty, groups the ordered rows by that field's value. + GroupBy string + // Offset skips this many rows (ungrouped) or groups (grouped) before the page. + Offset int + // Limit caps the page to this many rows (ungrouped) or groups (grouped); 0 + // means no limit. + Limit int + // Aggs are computed over the whole filtered set (View.Aggregates) and, when + // grouped, over each group (Group.Aggregates). + Aggs []Agg +} + +// Group is one bucket of a grouped View: the shared key value, the group's rows +// (in the query's sort order), and its per-group aggregates. +type Group struct { + Key Value + Rows []Record + Aggregates map[string]Value +} + +// View is the result of applying a Query. When the query is ungrouped, Rows is +// the requested page and Groups is nil; when it is grouped, Groups is the page +// of groups and Rows is nil. Total is the number of rows that matched the filter +// (before paging), and Aggregates holds the query's aggregates over that whole +// filtered set. +type View struct { + Rows []Record + Groups []Group + Total int + Aggregates map[string]Value +} + +// pageBounds clamps an Offset/Limit against a collection of n items and returns +// the [lo, hi) slice bounds of the page. A non-positive Limit means "to the +// end"; an Offset past the end yields an empty page. +func pageBounds(n, offset, limit int) (lo, hi int) { + if offset < 0 { + offset = 0 + } + if offset > n { + offset = n + } + hi = n + if limit > 0 && offset+limit < n { + hi = offset + limit + } + return offset, hi +} diff --git a/record.go b/record.go new file mode 100644 index 0000000..097beca --- /dev/null +++ b/record.go @@ -0,0 +1,147 @@ +// Copyright (c) 2026 the go-widgets/data authors. All rights reserved. +// Use of this source code is governed by a BSD-3-Clause license that can be +// found in the LICENSE file at the root of this repository. + +package data + +import ( + "errors" + "fmt" + "sort" +) + +// Record is one row: a set of named typed cells. It is a plain map so callers +// build and read rows with ordinary Go, while the Schema gives it type and the +// canonical encoder gives it a stable serialization (fields are always emitted +// in sorted-name order, so map iteration order never leaks into a comparison). +type Record map[string]Value + +// clone returns an independent copy so a proxy never hands out its internal row. +func (r Record) clone() Record { + out := make(Record, len(r)) + for k, v := range r { + out[k] = v + } + return out +} + +// sortedNames returns the record's field names in ascending order — the order +// the canonical encoder walks, and a convenient deterministic iteration order. +func (r Record) sortedNames() []string { + names := make([]string, 0, len(r)) + for k := range r { + names = append(names, k) + } + sort.Strings(names) + return names +} + +// Rule validates one cell value, returning a non-nil error (whose text is the +// message shown to the user) when the value fails. It mirrors the string-rule +// shape of go-widgets/toolkit's validation, lifted to a typed Value so a schema +// can validate numbers and booleans, not only text. +type Rule func(Value) error + +// Required rejects a zero value for the field's kind: an empty string, a zero +// number, or false. Use it to demand a present, non-default cell. +func Required(msg string) Rule { + return func(v Value) error { + zero := v == Value{Kind: v.Kind} + if zero { + return errors.New(msg) + } + return nil + } +} + +// StrMinLen rejects a string value shorter than n runes. +func StrMinLen(n int, msg string) Rule { + return func(v Value) error { + if len([]rune(v.Str)) < n { + return errors.New(msg) + } + return nil + } +} + +// StrMaxLen rejects a string value longer than n runes. +func StrMaxLen(n int, msg string) Rule { + return func(v Value) error { + if len([]rune(v.Str)) > n { + return errors.New(msg) + } + return nil + } +} + +// NumMin rejects a numeric value below lo (int and float compared as float64). +func NumMin(lo float64, msg string) Rule { + return func(v Value) error { + if v.num() < lo { + return errors.New(msg) + } + return nil + } +} + +// NumMax rejects a numeric value above hi. +func NumMax(hi float64, msg string) Rule { + return func(v Value) error { + if v.num() > hi { + return errors.New(msg) + } + return nil + } +} + +// Field declares one column of a Schema: its name, its scalar Kind, and any +// validation Rules run against a row's value for it. +type Field struct { + Name string + Kind Kind + Rules []Rule +} + +// Schema is an ordered list of Fields — the typed shape a Record must satisfy. +type Schema struct { + Fields []Field +} + +// Field looks a field up by name. +func (s Schema) Field(name string) (Field, bool) { + for _, f := range s.Fields { + if f.Name == name { + return f, true + } + } + return Field{}, false +} + +// Validate checks a Record against the schema: every declared field must be +// present with the declared Kind and must pass its Rules, and the record must +// carry no field the schema does not declare. It returns the first violation, so +// the message is the one to surface. A nil error means the row is well-formed. +func (s Schema) Validate(r Record) error { + declared := make(map[string]struct{}, len(s.Fields)) + for _, f := range s.Fields { + declared[f.Name] = struct{}{} + v, ok := r[f.Name] + if !ok { + return fmt.Errorf("missing field %q", f.Name) + } + if v.Kind != f.Kind { + return fmt.Errorf("field %q: want kind %d, got %d", f.Name, f.Kind, v.Kind) + } + for _, rule := range f.Rules { + if err := rule(v); err != nil { + return fmt.Errorf("field %q: %w", f.Name, err) + } + } + } + for _, name := range r.sortedNames() { + if _, ok := declared[name]; !ok { + return fmt.Errorf("unknown field %q", name) + } + } + return nil +} diff --git a/record_test.go b/record_test.go new file mode 100644 index 0000000..6949bc3 --- /dev/null +++ b/record_test.go @@ -0,0 +1,109 @@ +// Copyright (c) 2026 the go-widgets/data authors. All rights reserved. +// Use of this source code is governed by a BSD-3-Clause license that can be +// found in the LICENSE file at the root of this repository. + +package data + +import ( + "strings" + "testing" +) + +// testSchema is a small people schema reused across the package's tests. +func testSchema() Schema { + return Schema{Fields: []Field{ + {Name: "id", Kind: KindInt}, + {Name: "name", Kind: KindString, Rules: []Rule{Required("name required"), StrMaxLen(10, "too long")}}, + {Name: "team", Kind: KindString}, + {Name: "salary", Kind: KindFloat, Rules: []Rule{NumMin(0, "no negative pay")}}, + {Name: "active", Kind: KindBool}, + }} +} + +func rec(id int64, name, team string, salary float64, active bool) Record { + return Record{ + "id": Int(id), "name": String(name), "team": String(team), + "salary": Float(salary), "active": Bool(active), + } +} + +func TestRecordCloneIsIndependent(t *testing.T) { + r := rec(1, "a", "x", 10, true) + c := r.clone() + c["name"] = String("mutated") + if r["name"] != String("a") { + t.Fatal("clone aliased original") + } +} + +func TestSchemaFieldLookup(t *testing.T) { + s := testSchema() + if f, ok := s.Field("name"); !ok || f.Kind != KindString { + t.Fatalf("Field(name) = %+v ok=%v", f, ok) + } + if _, ok := s.Field("nope"); ok { + t.Fatal("Field(nope) should miss") + } +} + +func TestSchemaValidate(t *testing.T) { + s := testSchema() + if err := s.Validate(rec(1, "ok", "x", 5, true)); err != nil { + t.Fatalf("valid row rejected: %v", err) + } + + // Missing field. + miss := rec(1, "ok", "x", 5, true) + delete(miss, "team") + if err := s.Validate(miss); err == nil || !strings.Contains(err.Error(), "missing field") { + t.Fatalf("missing: %v", err) + } + + // Wrong kind. + wrong := rec(1, "ok", "x", 5, true) + wrong["id"] = String("nope") + if err := s.Validate(wrong); err == nil || !strings.Contains(err.Error(), "want kind") { + t.Fatalf("wrong kind: %v", err) + } + + // Rule failure (Required on empty name). + empty := rec(1, "", "x", 5, true) + if err := s.Validate(empty); err == nil || !strings.Contains(err.Error(), "name required") { + t.Fatalf("required: %v", err) + } + + // Unknown field. + extra := rec(1, "ok", "x", 5, true) + extra["ghost"] = Int(9) + if err := s.Validate(extra); err == nil || !strings.Contains(err.Error(), "unknown field") { + t.Fatalf("unknown: %v", err) + } +} + +func TestRules(t *testing.T) { + // Required across kinds: zero of each kind fails, non-zero passes. + req := Required("req") + for _, z := range []Value{String(""), Int(0), Float(0), Bool(false)} { + if req(z) == nil { + t.Fatalf("Required should reject zero %+v", z) + } + } + for _, nz := range []Value{String("x"), Int(1), Float(0.1), Bool(true)} { + if req(nz) != nil { + t.Fatalf("Required should accept %+v", nz) + } + } + + if StrMinLen(3, "m")(String("ab")) == nil || StrMinLen(3, "m")(String("abc")) != nil { + t.Fatal("StrMinLen") + } + if StrMaxLen(2, "m")(String("abc")) == nil || StrMaxLen(2, "m")(String("ab")) != nil { + t.Fatal("StrMaxLen") + } + if NumMin(0, "m")(Float(-1)) == nil || NumMin(0, "m")(Float(0)) != nil { + t.Fatal("NumMin") + } + if NumMax(10, "m")(Int(11)) == nil || NumMax(10, "m")(Int(10)) != nil { + t.Fatal("NumMax") + } +} diff --git a/store.go b/store.go new file mode 100644 index 0000000..c1755d7 --- /dev/null +++ b/store.go @@ -0,0 +1,97 @@ +// Copyright (c) 2026 the go-widgets/data authors. All rights reserved. +// Use of this source code is governed by a BSD-3-Clause license that can be +// found in the LICENSE file at the root of this repository. + +package data + +import ( + "context" + + "github.com/go-widgets/mvvm" +) + +// Codec converts between a caller's typed row R and the schema-typed Record the +// proxy stores. Supplying the two functions keeps Store reflection-free and lets +// the app choose exactly how its struct maps onto fields. +type Codec[R any] struct { + Encode func(R) Record + Decode func(Record) R +} + +// Store is the typed, bindable collection at the top of the spine. It holds a +// Query, runs it against a Proxy (local or remote — Store neither knows nor +// cares), and mirrors the resulting page of rows into an mvvm.ObservableList[R] +// so a view re-renders itself when the data changes. Sort/filter/group/page/ +// aggregate all live in the Query; Load re-materialises the list from the proxy. +type Store[R any] struct { + proxy Proxy + codec Codec[R] + query Query + items *mvvm.ObservableList[R] +} + +// NewStore builds a Store over proxy using codec to (de)serialise rows. Its +// Query starts empty (every row, no grouping/paging); set one with SetQuery. +func NewStore[R any](proxy Proxy, codec Codec[R]) *Store[R] { + return &Store[R]{proxy: proxy, codec: codec, items: mvvm.NewObservableList[R]()} +} + +// Items is the observable list a view binds to; it holds the decoded rows of the +// last Load (the flattened group rows when the query groups). +func (s *Store[R]) Items() *mvvm.ObservableList[R] { return s.items } + +// Query returns the current query. +func (s *Store[R]) Query() Query { return s.query } + +// SetQuery replaces the query used by the next Load. +func (s *Store[R]) SetQuery(q Query) { s.query = q } + +// Load runs the current query against the proxy, resets Items to the decoded page +// rows (group rows in order when grouped), and returns the full View so a caller +// can also read Total, Groups and Aggregates. On a proxy error Items is left +// unchanged. +func (s *Store[R]) Load(ctx context.Context) (View, error) { + view, err := s.proxy.Query(ctx, s.query) + if err != nil { + return View{}, err + } + rows := view.Rows + if view.Groups != nil { + rows = nil + for _, g := range view.Groups { + rows = append(rows, g.Rows...) + } + } + decoded := make([]R, len(rows)) + for i, r := range rows { + decoded[i] = s.codec.Decode(r) + } + s.items.Clear() + s.items.Append(decoded...) + return view, nil +} + +// Add inserts a typed row, then reloads so Items reflects the new data through +// the current query. +func (s *Store[R]) Add(ctx context.Context, row R) error { + return s.mutateThenLoad(ctx, Mutation{Kind: MutInsert, Record: s.codec.Encode(row)}) +} + +// Update replaces the row sharing this row's key, then reloads. +func (s *Store[R]) Update(ctx context.Context, row R) error { + return s.mutateThenLoad(ctx, Mutation{Kind: MutUpdate, Record: s.codec.Encode(row)}) +} + +// Delete removes the row whose key field equals key, then reloads. +func (s *Store[R]) Delete(ctx context.Context, key Value) error { + return s.mutateThenLoad(ctx, Mutation{Kind: MutDelete, Key: key}) +} + +// mutateThenLoad applies a write and, on success, reloads the list. +func (s *Store[R]) mutateThenLoad(ctx context.Context, m Mutation) error { + if err := s.proxy.Mutate(ctx, m); err != nil { + return err + } + _, err := s.Load(ctx) + return err +} diff --git a/store_test.go b/store_test.go new file mode 100644 index 0000000..153a101 --- /dev/null +++ b/store_test.go @@ -0,0 +1,135 @@ +// Copyright (c) 2026 the go-widgets/data authors. All rights reserved. +// Use of this source code is governed by a BSD-3-Clause license that can be +// found in the LICENSE file at the root of this repository. + +package data + +import ( + "context" + "errors" + "testing" +) + +// person is a typed row the Store maps to and from Records via a Codec. +type person struct { + ID int64 + Name string + Team string + Salary float64 + Active bool +} + +func personCodec() Codec[person] { + return Codec[person]{ + Encode: func(p person) Record { + return rec(p.ID, p.Name, p.Team, p.Salary, p.Active) + }, + Decode: func(r Record) person { + return person{ + ID: r["id"].Int, Name: r["name"].Str, Team: r["team"].Str, + Salary: r["salary"].Float, Active: r["active"].Bool, + } + }, + } +} + +func TestStoreLoadUngroupedMirrorsList(t *testing.T) { + m := mustMemory(t, sampleRows()...) + s := NewStore(m, personCodec()) + s.SetQuery(Query{Sorts: []Sort{{Field: "salary", Desc: true}}, Limit: 2}) + if got := s.Query().Limit; got != 2 { + t.Fatalf("Query() = %+v", s.Query()) + } + + // Track observable notifications so we prove the list drives a view. + var changes int + unsub := s.Items().SubscribeChanged(func() { changes++ }) + defer unsub() + + view, err := s.Load(context.Background()) + if err != nil { + t.Fatalf("Load: %v", err) + } + if view.Total != 5 || s.Items().Len() != 2 { + t.Fatalf("view.Total=%d items=%d", view.Total, s.Items().Len()) + } + if s.Items().At(0).Name != "cara" || s.Items().At(1).Name != "dan" { + t.Fatalf("items = %v, %v", s.Items().At(0), s.Items().At(1)) + } + if changes == 0 { + t.Fatal("Items never notified") + } +} + +func TestStoreLoadGroupedFlattens(t *testing.T) { + m := mustMemory(t, sampleRows()...) + s := NewStore(m, personCodec()) + s.SetQuery(Query{GroupBy: "team", Sorts: []Sort{{Field: "team"}, {Field: "id"}}}) + view, err := s.Load(context.Background()) + if err != nil { + t.Fatalf("Load: %v", err) + } + if len(view.Groups) != 2 { + t.Fatalf("groups = %d", len(view.Groups)) + } + // Flattened items = all rows in group-then-row order: blue(bob,dan), red(ann,cara,eve). + want := []string{"bob", "dan", "ann", "cara", "eve"} + if s.Items().Len() != len(want) { + t.Fatalf("items len = %d, want %d", s.Items().Len(), len(want)) + } + for i, n := range want { + if s.Items().At(i).Name != n { + t.Fatalf("item[%d] = %s, want %s", i, s.Items().At(i).Name, n) + } + } +} + +func TestStoreMutators(t *testing.T) { + m := mustMemory(t, rec(1, "a", "x", 1, true)) + s := NewStore(m, personCodec()) + s.SetQuery(Query{Sorts: []Sort{{Field: "id"}}}) + ctx := context.Background() + + if err := s.Add(ctx, person{ID: 2, Name: "b", Team: "y", Salary: 2, Active: true}); err != nil { + t.Fatalf("Add: %v", err) + } + if s.Items().Len() != 2 { // reload happened + t.Fatalf("after Add items = %d", s.Items().Len()) + } + if err := s.Update(ctx, person{ID: 2, Name: "bee", Team: "y", Salary: 3, Active: true}); err != nil { + t.Fatalf("Update: %v", err) + } + if s.Items().At(1).Name != "bee" { + t.Fatalf("update not reflected: %v", s.Items().At(1)) + } + if err := s.Delete(ctx, Int(1)); err != nil { + t.Fatalf("Delete: %v", err) + } + if s.Items().Len() != 1 || s.Items().At(0).ID != 2 { + t.Fatalf("after Delete items = %v", s.Items().Slice()) + } + + // Mutation error path: duplicate insert propagates and does not reload. + if err := s.Add(ctx, person{ID: 2, Name: "dup", Team: "y", Salary: 1, Active: true}); !errors.Is(err, ErrKeyExists) { + t.Fatalf("dup Add err = %v", err) + } +} + +// failProxy makes Query fail, to cover Store.Load's error path. +type failProxy struct{ Proxy } + +func (failProxy) Query(context.Context, Query) (View, error) { + return View{}, errors.New("boom") +} + +func TestStoreLoadErrorLeavesItems(t *testing.T) { + m := mustMemory(t, rec(1, "a", "x", 1, true)) + s := NewStore[person](failProxy{Proxy: m}, personCodec()) + s.Items().Append(person{ID: 7, Name: "keep"}) + if _, err := s.Load(context.Background()); err == nil { + t.Fatal("expected Load error") + } + if s.Items().Len() != 1 || s.Items().At(0).Name != "keep" { + t.Fatalf("items disturbed on error: %v", s.Items().Slice()) + } +} diff --git a/value.go b/value.go new file mode 100644 index 0000000..e833d82 --- /dev/null +++ b/value.go @@ -0,0 +1,141 @@ +// Copyright (c) 2026 the go-widgets/data authors. All rights reserved. +// Use of this source code is governed by a BSD-3-Clause license that can be +// found in the LICENSE file at the root of this repository. + +// Package data is the headless data spine of the go-widgets ecosystem: a typed +// record model with validation, a collection with sort/filter/group/pagination/ +// aggregation, and a pluggable proxy so the very same query pipeline runs +// in-process (MemoryProxy) or against a remote service (the grpcproxy +// subpackage) — natively and in a browser/wasm build alike. It imports only the +// standard library and go-widgets/mvvm, so nothing here depends on a GUI. +package data + +import "strconv" + +// Kind is the scalar type of a Value. The set is deliberately small — the four +// types a data grid needs — so a Value stays comparable and round-trips through +// the wire codec without loss. +type Kind uint8 + +const ( + // KindString is a UTF-8 text value. + KindString Kind = iota + // KindInt is a signed 64-bit integer value. + KindInt + // KindFloat is a 64-bit IEEE-754 float value. + KindFloat + // KindBool is a boolean value. + KindBool +) + +// Value is one typed scalar cell. It is a tagged union kept comparable (no +// slices or maps) so it can be a map key, sorted, and compared by ==. Only the +// field selected by Kind is meaningful; the others hold their zero value. +type Value struct { + Kind Kind + Str string + Int int64 + Float float64 + Bool bool +} + +// String makes a KindString Value. +func String(s string) Value { return Value{Kind: KindString, Str: s} } + +// Int makes a KindInt Value. +func Int(i int64) Value { return Value{Kind: KindInt, Int: i} } + +// Float makes a KindFloat Value. +func Float(f float64) Value { return Value{Kind: KindFloat, Float: f} } + +// Bool makes a KindBool Value. +func Bool(b bool) Value { return Value{Kind: KindBool, Bool: b} } + +// num reports the Value as a float64 for numeric aggregation and comparison: the +// int or float payload for the numeric kinds, else 0. Callers gate on Kind +// before relying on it for non-numeric values. +func (v Value) num() float64 { + switch v.Kind { + case KindInt: + return float64(v.Int) + case KindFloat: + return v.Float + default: + return 0 + } +} + +// compare orders two Values totally and deterministically. Values of different +// kinds order by Kind first (so a mixed column still has a stable order); within +// a kind they order naturally — lexicographically for strings, numerically for +// int/float (compared as float64 so an int and a float column sort sensibly), +// and false b: + return 1 + default: + return 0 + } +} + +func cmpStr(a, b string) int { + switch { + case a < b: + return -1 + case a > b: + return 1 + default: + return 0 + } +} + +func cmpFloat(a, b float64) int { + switch { + case a < b: + return -1 + case a > b: + return 1 + default: + return 0 + } +} diff --git a/value_test.go b/value_test.go new file mode 100644 index 0000000..172a796 --- /dev/null +++ b/value_test.go @@ -0,0 +1,68 @@ +// Copyright (c) 2026 the go-widgets/data authors. All rights reserved. +// Use of this source code is governed by a BSD-3-Clause license that can be +// found in the LICENSE file at the root of this repository. + +package data + +import "testing" + +func TestValueConstructorsAndNum(t *testing.T) { + if v := String("hi"); v.Kind != KindString || v.Str != "hi" { + t.Fatalf("String = %+v", v) + } + if v := Int(7); v.Kind != KindInt || v.Int != 7 || v.num() != 7 { + t.Fatalf("Int = %+v num=%v", v, v.num()) + } + if v := Float(1.5); v.Kind != KindFloat || v.Float != 1.5 || v.num() != 1.5 { + t.Fatalf("Float = %+v", v) + } + if v := Bool(true); v.Kind != KindBool || !v.Bool || v.num() != 0 { + t.Fatalf("Bool = %+v num=%v", v, v.num()) + } + // num on a string is 0 (the default branch). + if String("x").num() != 0 { + t.Fatal("string num should be 0") + } +} + +func TestValueCompare(t *testing.T) { + // Different kinds order by Kind. + if String("z").compare(Int(1)) != -1 { // KindString(0) < KindInt(1) + t.Fatal("cross-kind order") + } + if Int(1).compare(String("a")) != 1 { + t.Fatal("cross-kind order reverse") + } + // Strings lexicographic. + if String("a").compare(String("b")) != -1 || String("b").compare(String("a")) != 1 || + String("a").compare(String("a")) != 0 { + t.Fatal("string compare") + } + // Numbers. + if Int(1).compare(Int(2)) != -1 || Int(2).compare(Int(1)) != 1 || Int(2).compare(Int(2)) != 0 { + t.Fatal("int compare") + } + if Float(1).compare(Float(2)) != -1 { + t.Fatal("float compare") + } + // Bools: false < true. + if Bool(false).compare(Bool(true)) != -1 || Bool(true).compare(Bool(false)) != 1 || + Bool(true).compare(Bool(true)) != 0 { + t.Fatal("bool compare") + } +} + +func TestValueCanonical(t *testing.T) { + cases := map[Value]string{ + String("hi"): "s:hi", + Int(-3): "i:-3", + Float(1.5): "f:1.5", + Bool(true): "b:true", + Bool(false): "b:false", + } + for v, want := range cases { + if got := v.canonical(); got != want { + t.Errorf("canonical(%+v) = %q, want %q", v, got, want) + } + } +}