diff --git a/automation/publish_signals.go b/automation/publish_signals.go index e4a1176..797d2da 100644 --- a/automation/publish_signals.go +++ b/automation/publish_signals.go @@ -100,7 +100,7 @@ type PublishSignals struct { // SignalsFilter can optionally be specified to limit which signals to // publish. - SignalsFilter query.FilterType + SignalsFilter query.Filter // TransformVersion should be changed if you want existing items to be // republished despite the source signal being unchanged. @@ -130,13 +130,7 @@ func (p PublishSignals) Do(ctx context.Context, c *clarify.Client, opts PublishO // We iterate signals without requesting the total count. This is an // optimization bet that total % limit == 0 is uncommon. - q := query.Query{ - Sort: []string{"id"}, - Limit: selectSignalsPageSize, - } - if p.SignalsFilter != nil { - q.Filter = p.SignalsFilter.Filter() - } + q := query.Where(p.SignalsFilter).Sort("id").Limit(selectSignalsPageSize) items := make(map[string]views.ItemSave) flush := func(integrationID string) error { @@ -150,7 +144,7 @@ func (p PublishSignals) Do(ctx context.Context, c *clarify.Client, opts PublishO opts.EncodeJSON(items) } if !opts.DryRun { - result, err := c.PublishSignals(integrationID, items).Do(ctx) + result, err := c.Admin().PublishSignals(integrationID, items).Do(ctx) if err != nil { return fmt.Errorf("failed to publish signals: %w", err) } @@ -166,7 +160,7 @@ func (p PublishSignals) Do(ctx context.Context, c *clarify.Client, opts PublishO } for _, id := range p.Integrations { - q.Skip = 0 + more := true for more { if err := ctx.Err(); err != nil { @@ -178,13 +172,12 @@ func (p PublishSignals) Do(ctx context.Context, c *clarify.Client, opts PublishO if err != nil { return err } - q.Skip += q.Limit - if len(items) >= publishSignalsPageSize { if err := flush(id); err != nil { return err } } + q = q.NextPage() } if err := flush(id); err != nil { @@ -198,9 +191,7 @@ func (p PublishSignals) Do(ctx context.Context, c *clarify.Client, opts PublishO // addItems adds items that require update to dest from all signals matching // the integration ID and query q. func (p PublishSignals) addItems(ctx context.Context, dest map[string]views.ItemSave, c *clarify.Client, integrationID string, q query.Query, opts PublishOptions) (bool, error) { - results, err := c.SelectSignals(integrationID). - Query(q). - Include("item").Do(ctx) + results, err := c.Admin().SelectSignals(integrationID, q).Include("item").Do(ctx) if err != nil { return false, err } @@ -252,12 +243,12 @@ func (p PublishSignals) addItems(ctx context.Context, dest map[string]views.Item if results.Meta.Total >= 0 { // More can be calculated exactly when the total count was requested (or // calculated for free by the backend). - more = q.Skip+len(results.Data) < results.Meta.Total + more = q.GetSkip()+len(results.Data) < results.Meta.Total } else { // Fallback to approximate the more value when total was not explicitly // requested. For large values of q.Limit, this approximation is likely // faster -- on average -- then to request a total count. - more = (len(results.Data) == q.Limit) + more = (len(results.Data) == q.GetLimit()) } return more, nil diff --git a/client.go b/client.go index d524492..7fde2b7 100644 --- a/client.go +++ b/client.go @@ -15,131 +15,268 @@ package clarify import ( + "github.com/clarify/clarify-go/fields" + "github.com/clarify/clarify-go/internal/request" "github.com/clarify/clarify-go/jsonrpc" - "github.com/clarify/clarify-go/jsonrpc/resource" + "github.com/clarify/clarify-go/query" "github.com/clarify/clarify-go/views" ) const ( - paramIntegration jsonrpc.ParamName = "integration" - paramData jsonrpc.ParamName = "data" + apiVersion = "1.1" + paramIntegration jsonrpc.ParamName = "integration" + paramData jsonrpc.ParamName = "data" + paramSignalsByInput jsonrpc.ParamName = "signalsByInput" + paramItemsBySignal jsonrpc.ParamName = "itemsBySignal" + paramQuery jsonrpc.ParamName = "query" + paramItems jsonrpc.ParamName = "items" + paramCalculations jsonrpc.ParamName = "calculations" + paramFormat jsonrpc.ParamName = "format" ) // Client allows calling JSON RPC methods against Clarify. type Client struct { - integration string - - h jsonrpc.Handler + ns IntegrationNamespace } // NewClient can be used to initialize an integration client from a // jsonrpc.Handler implementation. func NewClient(integration string, h jsonrpc.Handler) *Client { - return &Client{integration: integration, h: h} + return &Client{ns: IntegrationNamespace{integration: integration, h: h}} } -// Insert returns a new insert request that can be executed at will. Requires -// access to the integration namespace. Will insert the data to the integration -// set in c. -func (c *Client) Insert(data views.DataFrame) resource.Request[InsertResult] { - return methodInsert.NewRequest(c.h, paramIntegration.Value(c.integration), paramData.Value(data)) +// Insert returns a new request for inserting data to clarify. When referencing +// input IDs that don't exist for the current integration, new signals are +// created automatically on demand. +// +// c.Insert(data) is a short-hand for c.Integration().Insert(data). +func (c Client) Insert(data views.DataFrame) InsertRequest { + return c.ns.Insert(data) } -// InsertResult holds the result of an Insert operation. -type InsertResult struct { - SignalsByInput map[string]resource.CreateSummary `json:"signalsByInput"` +// SaveSignals returns a new request for updating signal meta-data in Clarify. +// When referencing input IDs that don't exist for the current integration, new +// signals are created automatically on demand. +// +// c.SaveSignals(inputs) si a short-hand for: +// +// c.Integration().SaveSignals(inputs) +func (c Client) SaveSignals(inputs map[string]views.SignalSave) SaveSignalRequest { + return c.ns.SaveSignals(inputs) } -var methodInsert = resource.Method[InsertResult]{ - APIVersion: "1.1rc1", - Method: "integration.insert", +// Integration return a handler for initializing methods that require access to +// the integration namespace. +// +// All integrations got access to the integration namespace. +func (c Client) Integration() IntegrationNamespace { + return c.ns } -// SaveSignals returns a new save signals request that can be modified though a -// chainable API before it's executed. Keys in inputs are scoped to the current -// integration. Requires access to the integration namespace. -func (c *Client) SaveSignals(inputs map[string]views.SignalSave) resource.SaveRequest[map[string]views.SignalSave, SaveSignalsResult] { - return methodSaveSignals.NewRequest(c.h, inputs, paramIntegration.Value(c.integration)) +// Admin return a handler for initializing methods that require access to the +// admin namespace. +// +// Access to the admin namespace must be explicitly granted per integration in +// the Clarify admin panel. Do not grant excessive permissions. +func (c Client) Admin() AdminNamespace { + return AdminNamespace{h: c.ns.h} } -// SaveSignalsResults holds the result of a SaveSignals operation. -type SaveSignalsResult struct { - SignalsByInput map[string]resource.SaveSummary `json:"signalsByInput"` +// Clarify return a handler for initializing methods that require access to +// the clarify namespace. +// +// Access to the clarify namespace must be explicitly granted per integration in +// the Clarify admin panel. Do not grant excessive permissions. +func (c Client) Clarify() ClarifyNamespace { + return ClarifyNamespace{h: c.ns.h} } -var methodSaveSignals = resource.SaveMethod[map[string]views.SignalSave, SaveSignalsResult]{ - APIVersion: "1.1rc1", - Method: "integration.saveSignals", - DataParam: "inputs", +type IntegrationNamespace struct { + integration string + + h jsonrpc.Handler } -// PublishSignals returns a new request for publishing signals as Items. -// Requires access to the admin namespace. -// -// Disclaimer: this method is based on a pre-release of the Clarify API, and -// might be unstable or stop working. -func (c *Client) PublishSignals(integration string, itemsBySignal map[string]views.ItemSave) resource.SaveRequest[map[string]views.ItemSave, PublishSignalsResult] { - return methodPublishSignals.NewRequest(c.h, itemsBySignal, paramIntegration.Value(integration)) +// Insert returns a new request for inserting data to clarify. When referencing +// input IDs that don't exist for the current integration, new signals are +// created automatically on demand. +func (ns IntegrationNamespace) Insert(data views.DataFrame) InsertRequest { + return methodInsert.NewRequest(ns.h, paramIntegration.Value(ns.integration), paramData.Value(data)) +} + +type InsertRequest = request.Request[InsertResult] + +// InsertResult holds the result of an Insert operation. +type InsertResult struct { + SignalsByInput map[string]views.CreateSummary `json:"signalsByInput"` } -// PublishSignalsResult holds the result of a PublishSignals operation. -type PublishSignalsResult struct { - ItemsBySignals map[string]resource.SaveSummary `json:"itemsBySignal"` +var methodInsert = request.Method[InsertResult]{ + APIVersion: apiVersion, + Method: "integration.insert", } -var methodPublishSignals = resource.SaveMethod[map[string]views.ItemSave, PublishSignalsResult]{ - APIVersion: "1.1rc1", - Method: "admin.publishSignals", - DataParam: "itemsBySignal", +// SaveSignals returns a new request for updating signal meta-data in Clarify. +// When referencing input IDs that don't exist for the current integration, new +// signals are created automatically on demand. +func (ns IntegrationNamespace) SaveSignals(inputs map[string]views.SignalSave) SaveSignalRequest { + return methodSaveSignals.NewRequest(ns.h, + paramIntegration.Value(ns.integration), + paramSignalsByInput.Value(inputs), + ) +} + +type ( + // SaveSignalRequest describe an initialized integration.saveSignals RPC + // request with access to a request handler. + SaveSignalRequest = request.Request[SaveSignalsResult] + + // SaveSignalsResults describe the result format for a SaveSignalRequest. + SaveSignalsResult struct { + SignalsByInput map[string]views.SaveSummary `json:"signalsByInput"` + } +) + +var methodSaveSignals = request.Method[SaveSignalsResult]{ + APIVersion: apiVersion, + Method: "integration.saveSignals", +} + +type AdminNamespace struct { + h jsonrpc.Handler } // SelectSignals returns a new request for querying signals and related // resources. -// -// Disclaimer: this method is based on a pre-release of the Clarify API, and -// might be unstable or stop working. -func (c *Client) SelectSignals(integration string) resource.SelectRequest[SelectSignalsResult] { - return methodSelectSignals.NewRequest(c.h, paramIntegration.Value(integration)) +func (ns AdminNamespace) SelectSignals(integration string, q query.Query) SelectSignalsRequest { + return methodSelectSignals.NewRequest(ns.h, + paramIntegration.Value(integration), + paramQuery.Value(q), + paramFormat.Value(views.SelectionFormat{ + DataAsArray: true, + GroupIncludedByType: true, + }), + ) } -// SelectSignalsResult holds the result of a SelectSignals operation. -type SelectSignalsResult = resource.Selection[views.Signal, views.SignalInclude] +type ( + // SelectSignalsRequest describe an initialized admin.selectSignals RPC + // request with access to a request handler. + SelectSignalsRequest = request.WithInclude[SelectSignalsResult] + + // SelectSignalsResult describe the result format for a + // SelectSignalsRequest. + SelectSignalsResult = views.Selection[[]views.Signal, views.SignalInclude] +) -var methodSelectSignals = resource.SelectMethod[SelectSignalsResult]{ - APIVersion: "1.1rc1", +var methodSelectSignals = request.IncludeMethod[SelectSignalsResult]{ + APIVersion: apiVersion, Method: "admin.selectSignals", } -// SelectItems returns a new request for querying signals and related -// resources. -// -// Disclaimer: this method is based on a pre-release of the Clarify API, and -// might be unstable or stop working. -func (c *Client) SelectItems() resource.SelectRequest[SelectItemsResult] { - return methodSelectItems.NewRequest(c.h) +// PublishSignals returns a new request for publishing signals as items. +func (ns AdminNamespace) PublishSignals(integration string, itemsBySignal map[string]views.ItemSave) PublishSignalsRequest { + return methodPublishSignals.NewRequest(ns.h, + paramIntegration.Value(integration), + paramItemsBySignal.Value(itemsBySignal), + ) +} + +type ( + // PublishSignalsRequest describe an initialized admin.publishSignal RPC + // request with access to a request handler. + PublishSignalsRequest = request.Request[PublishSignalsResult] + + // PublishSignalsResult describe the result format for a + // PublishSignalsRequest. + PublishSignalsResult struct { + ItemsBySignals map[string]views.SaveSummary `json:"itemsBySignal"` + } +) + +var methodPublishSignals = request.Method[PublishSignalsResult]{ + APIVersion: apiVersion, + Method: "admin.publishSignals", +} + +type ClarifyNamespace struct { + h jsonrpc.Handler +} + +// SelectItems returns a request for querying items. +func (ns ClarifyNamespace) SelectItems(q query.Query) SelectItemsRequest { + return methodSelectItems.NewRequest(ns.h, + paramQuery.Value(q), + paramFormat.Value(views.SelectionFormat{ + DataAsArray: true, + GroupIncludedByType: true, + }), + ) } -// SelectItemsResult holds the result of a SelectItems operation. -type SelectItemsResult = resource.Selection[views.Item, views.ItemInclude] +type ( + // SelectItemsRequest describe an initialized clarify.selectItems RPC + // request with access to a request handler. + SelectItemsRequest = request.WithInclude[SelectItemsResult] + + // SelectItemsResult describe the result format for a SelectItemsRequest. + SelectItemsResult = views.Selection[[]views.Item, views.ItemInclude] +) -var methodSelectItems = resource.SelectMethod[SelectItemsResult]{ - APIVersion: "1.1rc1", +var methodSelectItems = request.IncludeMethod[SelectItemsResult]{ + APIVersion: apiVersion, Method: "clarify.selectItems", } -// DataFrame returns a new request from retrieving data from clarify. The -// request can be furthered modified by a chainable API before it's executed. By -// default, the data section is set to be included in the response. -// -// Disclaimer: this method is based on a pre-release of the Clarify API, and -// might be unstable or stop working. -func (c *Client) DataFrame() DataFrameRequest { - return DataFrameRequest{ - parent: methodDataFrame.NewRequest(c.h), - } +// DataFrame returns a new request from retrieving raw or aggregated data from +// Clarify. When a data query rollup is specified, data is aggregated using the +// default aggregation methods for each item is used. That is statistical +// aggregation values (count, min, max, sum, avg) for numeric items and a state +// histogram aggregation (seconds spent in each state per bucket) for enum +// items. +func (ns ClarifyNamespace) DataFrame(items query.Query, data query.Data) DataFrameRequest { + return methodDataFrame.NewRequest(ns.h, + paramQuery.Value(items), + paramData.Value(data), + paramFormat.Value(views.SelectionFormat{ + GroupIncludedByType: true, + }), + ) } -var methodDataFrame = resource.SelectMethod[DataFrameResult]{ - APIVersion: "1.1rc1", +type ( + DataFrameRequest = request.WithInclude[DataFrameResult] + DataFrameResult = views.Selection[views.DataFrame, views.DataFrameInclude] +) + +var methodDataFrame = request.IncludeMethod[DataFrameResult]{ + APIVersion: apiVersion, Method: "clarify.dataFrame", } + +// Evaluate returns a new request for retrieving aggregated data from Clarify, +// including server-side formula evaluation. +func (ns ClarifyNamespace) Evaluate(items []fields.ItemAggregation, calculations []fields.Calculation, data query.Data) EvaluateRequest { + return methodEvaluate.NewRequest(ns.h, + paramItems.Value(items), + paramCalculations.Value(calculations), + paramData.Value(data), + paramFormat.Value(views.SelectionFormat{ + GroupIncludedByType: true, + }), + ) +} + +type ( + // EvaluateRequest describe an initialized clarify.evaluate RPC request with + // access to a request handler. + EvaluateRequest = request.WithInclude[EvaluateResult] + + // EvaluateResult describe the result format for a EvaluateRequest. + EvaluateResult = views.Selection[views.DataFrame, views.DataFrameInclude] +) + +var methodEvaluate = request.IncludeMethod[EvaluateResult]{ + APIVersion: apiVersion, + Method: "clarify.evaluate", +} diff --git a/credentials.go b/credentials.go index 8c75a79..cfdec54 100644 --- a/credentials.go +++ b/credentials.go @@ -1,4 +1,4 @@ -// Copyright 2022 Searis AS +// Copyright 2022-2023 Searis AS // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -137,7 +137,7 @@ func (creds Credentials) Client(ctx context.Context) *Client { h = invalidRPCHandler{err: err} } - return &Client{integration: creds.Integration, h: h} + return &Client{ns: IntegrationNamespace{integration: creds.Integration, h: h}} } // HTTPHandler returns a low-level RPC handler that communicates over HTTP using diff --git a/example_test.go b/example_test.go index f5310dd..bad26d7 100644 --- a/example_test.go +++ b/example_test.go @@ -1,4 +1,4 @@ -// Copyright 2022 Searis AS +// Copyright 2022-2023 Searis AS // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -25,7 +25,7 @@ import ( "github.com/clarify/clarify-go/testdata" ) -func ExampleClient_SelectSignals() { +func ExampleAdminNamespace_SelectSignals() { const integrationID = "c8ktonqsahsmemfs7lv0" // In this example we use a mock client; real code should insted initialize @@ -40,13 +40,11 @@ func ExampleClient_SelectSignals() { ctx := context.Background() - res, err := c.SelectSignals(integrationID). - Filter( + res, err := c.Admin().SelectSignals(integrationID, + query.Where( query.Field("id", query.In("c8keagasahsp3cpvma20", "c8l8bc2sahsgjg5cckcg")), - ). - Limit(1). - Include("items"). - Do(ctx) + ).Limit(1), + ).Include("items").Do(ctx) if err != nil { fmt.Println("error:", err) @@ -74,7 +72,7 @@ func ExampleClient_SelectSignals() { // included.items.0.meta.annotations: map[clarify/clarify-go/from/signal/attributes-hash:220596a7b7b4ea2ac5abb6a13e6198f161443226 clarify/clarify-go/from/signal/id:c8keagasahsp3cpvma20] } -func ExampleClient_DataFrame() { +func ExampleClarifyNamespace_DataFrame() { const integrationID = "c8ktonqsahsmemfs7lv0" const itemID = "c8l95d2sahsh22imiabg" @@ -90,18 +88,17 @@ func ExampleClient_DataFrame() { ctx := context.Background() - res, err := c.DataFrame(). - Filter( + res, err := c.Clarify().DataFrame( + query.Where( query.Field("id", query.In("c8keagasahsp3cpvma20")), - ). - Limit(1). - TimeRange( - time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC), - time.Date(2022, 1, 1, 4, 0, 0, 0, time.UTC), - ). - RollupBucket(time.Hour). - Include("items"). - Do(ctx) + ).Limit(1), + query.DataWhere( + query.TimeRange( + time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC), + time.Date(2022, 1, 1, 4, 0, 0, 0, time.UTC), + ), + ).RollupDuration(time.Hour, time.Monday), + ).Include("items").Do(ctx) if err != nil { fmt.Println("error:", err) diff --git a/examples/data_frame/main.go b/examples/data_frame/main.go index f420006..68ff2e1 100644 --- a/examples/data_frame/main.go +++ b/examples/data_frame/main.go @@ -24,9 +24,13 @@ func main() { // To select item data or meta-data, you must grant the integration access // to the "clarify" namespace in the Clarify admin panel. - result, err := client.DataFrame().TimeRange(t1, t2).RollupBucket(time.Hour).Filter( - query.Field("annotations.clarify/clarify-go/example/name", query.Equal("publish_signals")), - ).Limit(10).Do(ctx) + q := query. + Where(query.Field("annotations.clarify/clarify-go/example/name", query.Equal("publish_signals"))). + Limit(10) + dq := query. + DataWhere(query.TimeRange(t1, t2)). + RollupDuration(time.Hour, time.Monday) + result, err := client.Clarify().DataFrame(q, dq).Do(ctx) if err != nil { panic(err) } diff --git a/examples/devdata_cli/commands.go b/examples/devdata_cli/commands.go index b3aa3b1..d256cae 100644 --- a/examples/devdata_cli/commands.go +++ b/examples/devdata_cli/commands.go @@ -1,4 +1,4 @@ -// Copyright 2022 Searis AS +// Copyright 2022-2023 Searis AS // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -72,6 +72,7 @@ func rootCommand() *ffcli.Command { p.selectItemsCommand(), p.publishSignalsCommand(), p.dataFrameCommand(), + p.evaluateCommand(), }, } } @@ -203,7 +204,9 @@ func (p *program) insert(ctx context.Context, config insertConfig) error { if config.points < 1 { return fmt.Errorf("-points can not be below 1") } - rand.Seed(time.Now().UnixNano()) + r := rand.New( + rand.NewSource(time.Now().UnixNano()), + ) startTime := config.startTime.Truncate(config.truncate) endTime := startTime.Add(config.interval * time.Duration(config.points)) @@ -230,7 +233,7 @@ func (p *program) insert(ctx context.Context, config insertConfig) error { return ctx.Err() default: } - ds[fields.AsTimestamp(t)] = rand.Float64() * 100 + ds[fields.AsTimestamp(t)] = r.Float64() * 100 } df[k] = ds } @@ -357,20 +360,20 @@ func (p *program) selectSignals(ctx context.Context, config selectSignalsConfig) config.integration = p.defaultIntegration } - req := p.client.SelectSignals(config.integration).Skip(config.skip).Limit(config.limit) - if config.includeItem { - req = req.Include("item") - } - if len(config.sort) > 0 { - req = req.Sort(config.sort...) - } + q := query.Where().Skip(config.skip).Limit(config.limit).Sort(config.sort...) if config.filter != "" { var f query.Filter if err := json.Unmarshal([]byte(config.filter), &f); err != nil { return fmt.Errorf("-filter: %w", err) } - req = req.Filter(f) + q = q.Where(f) } + + req := p.client.Admin().SelectSignals(config.integration, q) + if config.includeItem { + req = req.Include("item") + } + result, err := req.Do(ctx) if err != nil { return err @@ -385,6 +388,7 @@ type publishSignalsConfig struct { integration, filter string skip, limit int + sort []string } func (p *program) publishSignalsCommand() *ffcli.Command { @@ -396,6 +400,7 @@ func (p *program) publishSignalsCommand() *ffcli.Command { fs.StringVar(&config.annotationTransform, "annotation-transform", defaultTransformVersion, "Value to search for in the name annotation.") fs.StringVar(&config.integration, "integration", "", "Integration to publish signals from (defaults to integration from credentials file).") fs.StringVar(&config.filter, "filter", "", "Resource filter (JSON) that's combined with the a forced annotations filter for publish=true.") + fs.Var(stringSlice{target: &config.sort}, "sort", "Comma-separated list of fields to sort the result by.") fs.IntVar(&config.skip, "s", 0, "Number of signals to skip.") fs.IntVar(&config.limit, "n", 100, "Maximum number of signals to publish.") @@ -421,19 +426,19 @@ func (p *program) publishSignals(ctx context.Context, config publishSignalsConfi keySignalAttributesHash := config.annotationPrefix + "source/signal-attributes-hash" keySignalID := config.annotationPrefix + "source/signal-id" - selectReq := p.client.SelectSignals(config.integration). - Skip(config.skip). - Limit(config.limit). - Filter(query.Comparisons{"annotations." + keyPublish: query.Equal("true")}). - Include("item") + q := query.Where(query.Comparisons{ + "annotations." + keyPublish: query.Equal("true"), + }).Skip(config.skip).Limit(config.limit).Sort(config.sort...) + if config.filter != "" { var f query.Filter if err := json.Unmarshal([]byte(config.filter), &f); err != nil { return fmt.Errorf("-filter: %w", err) } - selectReq = selectReq.Filter(f) + q = q.Where(f) } - selectResult, err := selectReq.Do(ctx) + + selectResult, err := p.client.Admin().SelectSignals(config.integration, q).Include("item").Do(ctx) if err != nil { panic(err) } @@ -478,7 +483,7 @@ func (p *program) publishSignals(ctx context.Context, config publishSignalsConfi return nil } log.Printf("Updating %d/%d items", len(newItems), len(selectResult.Data)) - result, err := p.client.PublishSignals(config.integration, newItems).Do(ctx) + result, err := p.client.Admin().PublishSignals(config.integration, newItems).Do(ctx) if err != nil { return err } @@ -524,19 +529,15 @@ func (p *program) selectItemsCommand() *ffcli.Command { } func (p *program) selectItems(ctx context.Context, config selectItemsConfig) error { - req := p.client.SelectItems().Skip(config.skip).Limit(config.limit) + q := query.Where().Skip(config.skip).Limit(config.limit).Sort(config.sort...) if config.filter != "" { var f query.Filter if err := json.Unmarshal([]byte(config.filter), &f); err != nil { return fmt.Errorf("-filter: %w", err) } - req = req.Filter(f) + q = q.Where(f) } - if len(config.sort) > 0 { - req = req.Sort(config.sort...) - } - - result, err := req.Do(ctx) + result, err := p.client.Clarify().SelectItems(q).Do(ctx) if err != nil { return err } @@ -546,15 +547,20 @@ func (p *program) selectItems(ctx context.Context, config selectItemsConfig) err } type dataFrameConfig struct { + // Item query. skip, limit int filter string - includeItem bool sort []string + // Data query. startTime, endTime time.Time rollupDuration time.Duration + rollupMonths int rollupWindow bool last int + + // Formatting. + includeItems bool } func (p *program) dataFrameCommand() *ffcli.Command { @@ -565,16 +571,23 @@ func (p *program) dataFrameCommand() *ffcli.Command { config.endTime = config.startTime.Add(24 * time.Hour) fs := flag.NewFlagSet("devdata_cli data-frame", flag.ExitOnError) + + // Item query. fs.IntVar(&config.skip, "s", 0, "Number of items to skip.") fs.IntVar(&config.limit, "n", 50, "Maximum number of items to return.") - fs.IntVar(&config.last, "last", -1, "Limit response to last N samples.") fs.StringVar(&config.filter, "filter", "", "Resource filter (JSON).") fs.Var(stringSlice{target: &config.sort}, "sort", "Comma-separated list of fields to sort the result by.") - fs.BoolVar(&config.includeItem, "include-item", false, "Include related items.") + + // Data query. fs.Var(timeFlag{target: &config.startTime}, "start-time", "RFC 3339 timestamp of first data-point to include.") fs.Var(timeFlag{target: &config.endTime}, "end-time", "RFC 3339 timestamp of first data-point not to include.") fs.BoolVar(&config.rollupWindow, "rollup-window", false, "Use window rollup.") - fs.DurationVar(&config.rollupDuration, "rollup-duration", 0, "Set to positive duration to enable time-bucket rollup.") + fs.DurationVar(&config.rollupDuration, "rollup-duration", 0, "Set to positive duration to use duration bucket rollup.") + fs.IntVar(&config.rollupMonths, "rollup-months", 0, "Set to positive value to use month bucket rollup.") + fs.IntVar(&config.last, "last", -1, "Limit response to last N samples.") + + // Formatting. + fs.BoolVar(&config.includeItems, "include-items", false, "Include related items.") return &ffcli.Command{ Name: "data-frame", @@ -590,33 +603,31 @@ func (p *program) dataFrameCommand() *ffcli.Command { } func (p *program) dataFrame(ctx context.Context, config dataFrameConfig) error { - req := p.client.DataFrame().Skip(config.skip).Limit(config.limit).TimeRange(config.startTime, config.endTime) - expectSeriesPerItem := 1 - switch { - case config.rollupWindow: - expectSeriesPerItem = 5 - req = req.RollupWindow() - case config.rollupDuration > 0: - expectSeriesPerItem = 5 - req = req.RollupBucket(config.rollupDuration) - } - if config.last > 0 { - req = req.Last(config.last) - } - if config.includeItem { - req = req.Include("item") - } + q := query.Where().Skip(config.skip).Limit(config.limit).Sort(config.sort...) if config.filter != "" { var f query.Filter if err := json.Unmarshal([]byte(config.filter), &f); err != nil { return fmt.Errorf("-filter: %w", err) } - req = req.Filter(f) + q = q.Where(f) } - if len(config.sort) > 0 { - req = req.Sort(config.sort...) + dq := query.DataWhere(query.TimeRange(config.startTime, config.endTime)) + switch { + case config.rollupWindow: + dq = dq.RollupWindow() + case config.rollupMonths > 0: + dq = dq.RollupMonths(config.rollupMonths) + case config.rollupDuration > 0: + dq = dq.RollupDuration(config.rollupDuration, time.Monday) + } + if config.last > 0 { + dq = dq.Last(config.last) } + req := p.client.Clarify().DataFrame(q, dq) + if config.includeItems { + req = req.Include("item") + } result, err := req.Do(ctx) if err != nil { return err @@ -629,11 +640,10 @@ func (p *program) dataFrame(ctx context.Context, config dataFrameConfig) error { slices.Sort(keys) var i, samples int for _, k := range keys { - data := result.Data[k] samples += len(data) - if config.includeItem { + if config.includeItems { id, _, _ := strings.Cut(k, "_") for id != result.Included.Items[i].ID { i++ @@ -644,11 +654,107 @@ func (p *program) dataFrame(ctx context.Context, config dataFrameConfig) error { } } - if config.includeItem { - missing := len(result.Included.Items)*expectSeriesPerItem - len(result.Data) - log.Printf("Data frame summary: len(result.data): %d, len(result.included.items): %d, total samples: %d, missing series: %d", len(result.Data), len(result.Included.Items), samples, missing) - } else { - log.Printf("Data frame summary: len(result.data): %d, len(result.included.items): %d, total samples: %d", len(result.Data), len(result.Included.Items), samples) + log.Printf("Data frame summary: len(result.data): %d, len(result.included.items): %d, total samples: %d", len(result.Data), len(result.Included.Items), samples) + return p.EncodeJSON(result) +} + +type evaluateConfig struct { + // Items. + items string + calculations string + + // Data query. + startTime, endTime time.Time + rollupDuration time.Duration + rollupMonths int + rollupWindow bool + last int + + // Formatting. + includeItems bool +} + +func (p *program) evaluateCommand() *ffcli.Command { + var config evaluateConfig + + // Set default-times. + config.startTime = time.Now().Truncate(24 * time.Hour) + config.endTime = config.startTime.Add(24 * time.Hour) + + fs := flag.NewFlagSet("devdata_cli evaluate", flag.ExitOnError) + + // Items. + fs.StringVar(&config.items, "items", "[]", "Items (JSON array).") + fs.StringVar(&config.calculations, "calculations", "[]", "Calculations (JSON array).") + + // Data query. + fs.Var(timeFlag{target: &config.startTime}, "start-time", "RFC 3339 timestamp of first data-point to include.") + fs.Var(timeFlag{target: &config.endTime}, "end-time", "RFC 3339 timestamp of first data-point not to include.") + fs.BoolVar(&config.rollupWindow, "rollup-window", false, "Use window rollup.") + fs.DurationVar(&config.rollupDuration, "rollup-duration", 0, "Set to positive duration to use duration bucket rollup.") + fs.IntVar(&config.rollupMonths, "rollup-months", 0, "Set to positive value to use month bucket rollup.") + fs.IntVar(&config.last, "last", -1, "Limit response to last N samples.") + + // Formatting. + fs.BoolVar(&config.includeItems, "include-items", false, "Include related items.") + + return &ffcli.Command{ + Name: "evaluate", + ShortUsage: "devdata_cli evaluate [flags]", + ShortHelp: "Return a data frame with aggregated data.", + FlagSet: fs, + Exec: func(ctx context.Context, args []string) error { + p.init(ctx) + + return p.evaluate(ctx, config) + }, + } +} + +func (p *program) evaluate(ctx context.Context, config evaluateConfig) error { + var items []fields.ItemAggregation + if err := json.Unmarshal([]byte(config.items), &items); err != nil { + return fmt.Errorf("-items: %w", err) + } + var calculations []fields.Calculation + if err := json.Unmarshal([]byte(config.calculations), &calculations); err != nil { + return fmt.Errorf("-calculations: %w", err) } + dq := query.DataWhere(query.TimeRange(config.startTime, config.endTime)) + switch { + case config.rollupWindow: + dq = dq.RollupWindow() + case config.rollupMonths > 0: + dq = dq.RollupMonths(config.rollupMonths) + case config.rollupDuration > 0: + dq = dq.RollupDuration(config.rollupDuration, time.Monday) + } + if config.last > 0 { + dq = dq.Last(config.last) + } + + req := p.client.Clarify().Evaluate(items, calculations, dq) + if config.includeItems { + req = req.Include("item") + } + result, err := req.Do(ctx) + if err != nil { + return err + } + + keys := make([]string, 0, len(result.Data)) + for k := range result.Data { + keys = append(keys, k) + } + slices.Sort(keys) + var samples int + for _, k := range keys { + data := result.Data[k] + samples += len(data) + + log.Printf("len(result.data['%s']): %d\n", k, len(data)) + } + + log.Printf("Data frame summary: len(result.data): %d, len(result.included.items): %d, total samples: %d", len(result.Data), len(result.Included.Items), samples) return p.EncodeJSON(result) } diff --git a/examples/devdata_cli/go.mod b/examples/devdata_cli/go.mod index e62809c..8ce8ce7 100644 --- a/examples/devdata_cli/go.mod +++ b/examples/devdata_cli/go.mod @@ -3,16 +3,15 @@ module github.com/clarify/clarify-go/devdata_cli go 1.21 require ( - github.com/clarify/clarify-go v0.2.5 + github.com/clarify/clarify-go v0.3.0 github.com/peterbourgon/ff/v3 v3.4.0 ) require ( github.com/golang/protobuf v1.5.3 // indirect - golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect golang.org/x/oauth2 v0.12.0 // indirect google.golang.org/appengine v1.6.8 // indirect google.golang.org/protobuf v1.31.0 // indirect ) -replace github.com/clarify/clarify-go v0.2.5 => ../../ +replace github.com/clarify/clarify-go v0.3.0 => ../../ diff --git a/examples/publish_signals/main.go b/examples/publish_signals/main.go index 164cd40..00918d2 100644 --- a/examples/publish_signals/main.go +++ b/examples/publish_signals/main.go @@ -55,7 +55,7 @@ func main() { SignalsFilter: query.Comparisons{ "annotations." + keyExampleName: query.Equal(exampleName), "annotations." + keyExamplePublish: query.Equal(annotationTrue), - }.Filter(), + }, TransformVersion: transformVersion, Transforms: []func(item *views.ItemSave){ transformEnumValuesToFireEmoji, diff --git a/examples/save_signals/main.go b/examples/save_signals/main.go index 5bc28b1..cc3ae7c 100644 --- a/examples/save_signals/main.go +++ b/examples/save_signals/main.go @@ -61,8 +61,8 @@ func main() { 0: "not on fire", 1: "on fire", }, - SampleInterval: fields.AsFixedDuration(15 * time.Minute), - GapDetection: fields.AsFixedDuration(2 * time.Hour), + SampleInterval: fields.AsFixedDurationNullZero(15 * time.Minute), + GapDetection: fields.AsFixedDurationNullZero(2 * time.Hour), }, }, } diff --git a/examples/select_items/main.go b/examples/select_items/main.go index 9d5d927..0faaf3e 100644 --- a/examples/select_items/main.go +++ b/examples/select_items/main.go @@ -20,9 +20,11 @@ func main() { ctx := context.Background() client := creds.Client(ctx) - result, err := client.SelectItems().Filter(query.Comparisons{ - "annotations.clarify/clarify-go/example/name": query.Equal("publish_signals"), - }).Limit(10).Do(ctx) + result, err := client.Clarify().SelectItems( + query.Where(query.Comparisons{ + "annotations.clarify/clarify-go/example/name": query.Equal("publish_signals"), + }).Limit(10), + ).Do(ctx) if err != nil { panic(err) } diff --git a/examples/select_signals/main.go b/examples/select_signals/main.go index 97909b5..9266934 100644 --- a/examples/select_signals/main.go +++ b/examples/select_signals/main.go @@ -26,9 +26,12 @@ func main() { // configured to be something else. integrationID := creds.Integration - result, err := client.SelectSignals(integrationID).Filter(query.Comparisons{ - "annotations.clarify/clarify-go/example/name": query.Equal("save_signals"), - }).Limit(10).Do(ctx) + result, err := client.Admin().SelectSignals( + integrationID, + query.Where(query.Comparisons{ + "annotations.clarify/clarify-go/example/name": query.Equal("save_signals"), + }).Limit(10), + ).Do(ctx) if err != nil { panic(err) } diff --git a/fields/duration.go b/fields/duration.go index 5da9b37..f8eaf56 100644 --- a/fields/duration.go +++ b/fields/duration.go @@ -1,4 +1,4 @@ -// Copyright 2022 Searis AS +// Copyright 2022-2023 Searis AS // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -24,72 +24,275 @@ import ( "time" ) -// FixedDuration wraps a time.Duration so that it's JSON encoded as an RFC 3339 -// duration string. The zero-value is encoded as null. -type FixedDuration struct { - time.Duration -} +const ( + patternYearToFraction = `^(?P-)?P((?P\d+)Y)?((?P\d+)M)?((?P\d+)W)?((?P\d+)D)?(T((?P\d+)H)?((?P\d+)M)?((?P\d+(\.\d+)?)S)?)?$` + patternWeekToFraction = `^(?P-)?P((?P\d+)W)?((?P\d+)D)?(T((?P\d+)H)?((?P\d+)M)?((?P\d+(\.\d+)?)S)?)?$` +) var ( - _ interface { - json.Marshaler - fmt.Stringer - } = FixedDuration{} - _ json.Unmarshaler = (*FixedDuration)(nil) + reYearToFraction = regexp.MustCompile(patternYearToFraction) + reWeekToFraction = regexp.MustCompile(patternWeekToFraction) ) -const ( - patternWeekToFraction = `^(?P-)?P((?P\d+)W)?((?P\d+)D)?(T((?P\d+)H)?((?P\d+)M)?((?P\d+(\.\d+)?)S)?)?$` -) +// CalendarDurationNullZero is a variant of CalendarDuration that JSON encodes +// the zero-value to null. +type CalendarDurationNullZero CalendarDuration -var reWeekToFraction = regexp.MustCompile(patternWeekToFraction) +func (cd CalendarDurationNullZero) IsZero() bool { + return CalendarDuration(cd).IsZero() +} -// AsFixedDuration converts d to a FixedDuration. -func AsFixedDuration(d time.Duration) FixedDuration { - return FixedDuration{Duration: d} +func (cd CalendarDurationNullZero) String() string { + if cd.IsZero() { + return "" + } + return CalendarDuration(cd).String() } -// ParseFixedDuration parses a RFC 3339 string accepting weeks, days, hours, -// minute, seconds and fractions. -func ParseFixedDuration(s string) (FixedDuration, error) { - d, ok := parseWeekToFraction(s) +func (cd *CalendarDurationNullZero) UnmarshalJSON(data []byte) error { + if bytes.Equal(data, []byte(`null`)) { + cd.Duration = 0 + cd.Months = 0 + return nil + } + var s string + if err := json.Unmarshal(data, &s); err != nil { + return err + } + _cd, ok := parseYearToFraction(s) + if !ok { + return ErrBadCalendarDuration + } + + *cd = CalendarDurationNullZero(_cd) + return nil +} + +func (cd CalendarDurationNullZero) MarshalJSON() ([]byte, error) { + if cd.Duration == 0 && cd.Months == 0 { + return []byte(`null`), nil + } + s, err := formatCalendarDuration(CalendarDuration(cd)) + if err != nil { + return nil, err + } + return json.Marshal(s) +} + +// CalendarDuration allows encoding either a fixed duration or a monthly +// duration Setting both a month and a duration value is regarded an error, and +// will fail to encoded. +type CalendarDuration struct { + Months int + Duration time.Duration +} + +// MonthDuration returns a duration that spans a given number of months. +func MonthDuration(m int) CalendarDuration { + return CalendarDuration{Months: m} +} + +func (cd CalendarDuration) IsZero() bool { + return cd.Duration == 0 && cd.Months == 0 +} + +func (cd *CalendarDuration) UnmarshalText(b []byte) error { + _cd, ok := parseYearToFraction(string(b)) + if !ok { + return fmt.Errorf("json: %w", ErrBadCalendarDuration) + } + cd.Months = _cd.Months + cd.Duration = _cd.Duration + return nil +} + +func (cd CalendarDuration) String() string { + res, _ := formatCalendarDuration(cd) + return res +} + +func (dd CalendarDuration) MarshalText() ([]byte, error) { + s, err := formatCalendarDuration(dd) + if err != nil { + return nil, err + } + return []byte(s), nil +} + +// ParseCalendarDuration converts text-encoded RFC 3339 duration to its +// CalendarDuration representation. +func ParseCalendarDuration(s string) (CalendarDuration, error) { + dd, ok := parseYearToFraction(s) if !ok { - return FixedDuration{}, ErrBadFixedDuration + return CalendarDuration{}, ErrBadCalendarDuration + } + return dd, nil +} + +func formatCalendarDuration(dd CalendarDuration) (string, error) { + var s string + switch { + case dd.Months != 0 && dd.Duration != 0: + return "", fmt.Errorf("can't specify both months and duration") + case dd.Months != 0: + m := dd.Months + if m < 0 { + s = "-P" + m = -m + } else { + s = "P" + } + if y := m / 12; y > 0 { + s += fmt.Sprintf("%dY", y) + m %= 12 + } + if m > 0 { + s += fmt.Sprintf("%dM", m) + } + case dd.Duration != 0: + s = formatFixedDuration(dd.Duration) + default: + s = "PT0S" + } + + return s, nil +} + +func parseYearToFraction(s string) (CalendarDuration, bool) { + var err error + var di int64 + var df float64 + var dd CalendarDuration + sign := 1 + + matches := reYearToFraction.FindStringSubmatch(strings.ToUpper(s)) + if matches == nil { + return dd, false + } + for i, name := range reYearToFraction.SubexpNames() { + if matches[i] == "" || name == "" { + continue + } + switch name { + case "sign": + sign = -1 + case "years": + di, err = strconv.ParseInt(matches[i], 10, 64) + dd.Months += 12 * int(di) + case "months": + di, err = strconv.ParseInt(matches[i], 10, 64) + dd.Months += int(di) + case "weeks": + di, err = strconv.ParseInt(matches[i], 10, 64) + dd.Duration += time.Duration(di) * 7 * 24 * time.Hour + case "days": + di, err = strconv.ParseInt(matches[i], 10, 64) + dd.Duration += time.Duration(di) * 24 * time.Hour + case "hours": + di, err = strconv.ParseInt(matches[i], 10, 64) + dd.Duration += time.Duration(di) * time.Hour + case "minutes": + di, err = strconv.ParseInt(matches[i], 10, 64) + dd.Duration += time.Duration(di) * time.Minute + case "fractions": + df, err = strconv.ParseFloat(matches[i], 64) + dd.Duration += time.Duration(df * float64(time.Second)) + } + if err != nil { + // If this happens, it's a programming error that must be corrected; + // regex should validate the format for matches. + panic(fmt.Errorf("%s: %s", name, err)) + } + } + if dd.IsZero() { + return dd, false + } + + dd.Duration *= time.Duration(sign) + dd.Months *= sign + return dd, true +} + +// FixedDurationNullZero is a variant of FixedDuration that JSON encodes the +// zero-value as null. +type FixedDurationNullZero FixedDuration + +// AsFixedDurationNullZero converts d to a FixedDurationNullZero instance. +func AsFixedDurationNullZero(d time.Duration) FixedDurationNullZero { + return FixedDurationNullZero{Duration: d} +} + +// MarshalJSON implements json.Marshaler. +func (d FixedDurationNullZero) MarshalJSON() ([]byte, error) { + if d.Duration == 0 { + return []byte(`null`), nil } - return FixedDuration{Duration: d}, nil + return json.Marshal(formatFixedDuration(d.Duration)) } // UnmarshalJSON implements json.Unmarshaler. -func (d *FixedDuration) UnmarshalJSON(b []byte) error { +func (d *FixedDurationNullZero) UnmarshalJSON(b []byte) error { if bytes.Equal(b, []byte(`null`)) { d.Duration = 0 return nil } + var s string - err := json.Unmarshal(b, &s) - if err != nil { + if err := json.Unmarshal(b, &s); err != nil { return err } - _d, ok := parseWeekToFraction(s) if !ok { - return fmt.Errorf("json: %w", ErrBadFixedDuration) + return ErrBadFixedDuration } d.Duration = _d return nil } +// FixedDuration wraps a time.Duration so that it's JSON encoded as an RFC 3339 +// duration string. +type FixedDuration struct { + time.Duration +} + +// AsFixedDuration converts d to a FixedDuration instance. +func AsFixedDuration(d time.Duration) FixedDuration { + return FixedDuration{Duration: d} +} + func (d FixedDuration) String() string { return formatFixedDuration(d.Duration) } // MarshalJSON implements json.Marshaler. -func (d FixedDuration) MarshalJSON() ([]byte, error) { - if d.Duration == 0 { - return []byte(`null`), nil +func (d FixedDuration) MarshalText() ([]byte, error) { + return []byte(formatFixedDuration(d.Duration)), nil +} + +// ParseFixedDuration parses a RFC 3339 string accepting weeks, days, hours, +// minute, seconds and fractions. +func ParseFixedDuration(s string) (FixedDurationNullZero, error) { + d, ok := parseWeekToFraction(s) + if !ok { + return FixedDurationNullZero{}, ErrBadFixedDuration } - return json.Marshal(formatFixedDuration(d.Duration)) + return FixedDurationNullZero{Duration: d}, nil +} + +// UnmarshalJSON implements json.Unmarshaler. +func (d *FixedDuration) UnmarshalText(b []byte) error { + _d, ok := parseWeekToFraction(string(b)) + if !ok { + return ErrBadFixedDuration + } + + d.Duration = _d + return nil +} + +func (d FixedDurationNullZero) String() string { + return formatFixedDuration(d.Duration) } func formatFixedDuration(d time.Duration) string { diff --git a/fields/error.go b/fields/error.go index 97b273b..c73cfef 100644 --- a/fields/error.go +++ b/fields/error.go @@ -1,4 +1,4 @@ -// Copyright 2022 Searis AS +// Copyright 2022-2023 Searis AS // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -16,7 +16,8 @@ package fields // Parsing errors. const ( - ErrBadFixedDuration strErr = "must be RFC 3339 duration in range week to fraction" + ErrBadCalendarDuration strErr = "must be RFC 3339 duration in range year to fraction" + ErrBadFixedDuration strErr = "must be RFC 3339 duration in range week to fraction" ) type strErr string diff --git a/fields/evaluate.go b/fields/evaluate.go new file mode 100644 index 0000000..a31f8a5 --- /dev/null +++ b/fields/evaluate.go @@ -0,0 +1,133 @@ +// Copyright 2023 Searis AS +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package fields + +import ( + "encoding" + "encoding/json" + "fmt" +) + +const ( + DefaultAggregation AggregateMethod = iota + Count + Min + Max + Sum + Avg + StateHistogramSeconds + StateHistogramPercent + StateHistogramRate +) + +type AggregateMethod uint8 + +var ( + _ encoding.TextMarshaler = AggregateMethod(0) + _ encoding.TextUnmarshaler = (*AggregateMethod)(nil) +) + +func (m AggregateMethod) String() string { + b, err := m.MarshalText() + if err != nil { + return "%(INVALID)!" + } + return string(b) +} + +func (m AggregateMethod) MarshalText() ([]byte, error) { + switch m { + case DefaultAggregation: + return nil, nil + case Count: + return []byte("count"), nil + case Min: + return []byte("min"), nil + case Max: + return []byte("max"), nil + case Sum: + return []byte("sum"), nil + case Avg: + return []byte("avg"), nil + case StateHistogramSeconds: + return []byte("state-histogram-seconds"), nil + case StateHistogramPercent: + return []byte("state-histogram-percent"), nil + case StateHistogramRate: + return []byte("state-histogram-rate"), nil + } + return nil, fmt.Errorf("unknown aggregation method") +} + +func (m *AggregateMethod) UnmarshalText(data []byte) error { + switch string(data) { + case "": + *m = DefaultAggregation + case "count": + *m = Count + case "min": + *m = Min + case "max": + *m = Max + case "sum": + *m = Sum + case "avg": + *m = Avg + case "seconds", "state-histogram-seconds": + *m = StateHistogramSeconds + case "percent", "state-histogram-percent": + *m = StateHistogramPercent + case "rate", "state-histogram-rate": + *m = StateHistogramRate + default: + return fmt.Errorf("bad aggregation method") + } + return nil +} + +type ItemAggregation struct { + Alias string `json:"alias"` + ID string `json:"id"` + Aggregation AggregateMethod `json:"aggregation"` + State int `json:"state"` +} + +var ( + _ json.Marshaler = ItemAggregation{} +) + +func (ia ItemAggregation) MarshalJSON() ([]byte, error) { + var v any + switch ia.Aggregation { + case StateHistogramSeconds, StateHistogramPercent, StateHistogramRate: + type encType ItemAggregation + v = encType(ia) + default: + type encType struct { + Alias string `json:"alias"` + ID string `json:"id"` + Aggregation AggregateMethod `json:"aggregation"` + State int `json:"-"` + } + v = encType(ia) + + } + return json.Marshal(v) +} + +type Calculation struct { + Alias string `json:"alias"` + Formula string `json:"formula"` +} diff --git a/fields/timestamp_timescale_test.go b/fields/timestamp_timescale_test.go index b5754b8..80a08d6 100644 --- a/fields/timestamp_timescale_test.go +++ b/fields/timestamp_timescale_test.go @@ -1,4 +1,4 @@ -// Copyright 2022 Searis AS, 2017-2022 Timescale, Inc. +// Copyright 2022-2023 Searis AS, 2017-2022 Timescale, Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -14,8 +14,10 @@ package fields_test -// timestampTimeBucket is a direct Go translation of C code from Timescale. We -// use it as a reference check for our own implementation. +// timestampTimeBucket is a direct Go translation of C code from Timescale (but +// without custom origin and time-zone support). We use it as a reference check +// for our own implementation. +// // https://github.com/timescale/timescaledb/blob/a6b5f9002cf4f3894aa8cbced7f862a73784cada/src/time_bucket.c#L18 func timescaleTimeBucket(period, timestamp, offset, min, max int64) int64 { if period <= 0 { diff --git a/jsonrpc/resource/request_plain.go b/internal/request/method.go similarity index 72% rename from jsonrpc/resource/request_plain.go rename to internal/request/method.go index e8e90a1..06f8557 100644 --- a/jsonrpc/resource/request_plain.go +++ b/internal/request/method.go @@ -1,4 +1,4 @@ -// Copyright 2022 Searis AS +// Copyright 2022-2023 Searis AS // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package resource +package request import ( "context" @@ -36,8 +36,7 @@ func (cfg Method[R]) NewRequest(h jsonrpc.Handler, params ...jsonrpc.Param) Requ } } -// Request allows creating or updating properties based on a keyed -// relation. +// Request describe an initialized RPC request with access to a request handler. type Request[R any] struct { apiVersion string method string @@ -48,12 +47,16 @@ type Request[R any] struct { } // Do performs the request against the server and returns the result. -func (req Request[R]) Do(ctx context.Context, extraParams ...jsonrpc.Param) (*R, error) { - params := make([]jsonrpc.Param, 0, len(req.baseParams)+len(extraParams)) - params = append(params, req.baseParams...) - params = append(params, extraParams...) +func (req Request[R]) Do(ctx context.Context) (*R, error) { + return req.do(ctx) +} + +func (req Request[R]) do(ctx context.Context, params ...jsonrpc.Param) (*R, error) { + allParams := make([]jsonrpc.Param, 0, len(req.baseParams)+len(params)) + allParams = append(allParams, req.baseParams...) + allParams = append(allParams, params...) - rpcReq := jsonrpc.NewRequest(req.method, params...) + rpcReq := jsonrpc.NewRequest(req.method, allParams...) if req.apiVersion != "" { rpcReq.APIVersion = req.apiVersion } diff --git a/internal/request/method_with_insert.go b/internal/request/method_with_insert.go new file mode 100644 index 0000000..d68bc9c --- /dev/null +++ b/internal/request/method_with_insert.go @@ -0,0 +1,70 @@ +// Copyright 2023 Searis AS +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package request + +import ( + "context" + + "github.com/clarify/clarify-go/jsonrpc" +) + +const ( + includeParam jsonrpc.ParamName = "include" +) + +// Method is a constructor for Requests against a given RPC method. +type IncludeMethod[R any] struct { + APIVersion string + Method string +} + +func (cfg IncludeMethod[R]) NewRequest(h jsonrpc.Handler, params ...jsonrpc.Param) WithInclude[R] { + return WithInclude[R]{ + parent: Request[R]{ + apiVersion: cfg.APIVersion, + method: cfg.Method, + + baseParams: params, + h: h, + }, + } +} + +// WithInclude describe an initialized RPC request with access to a request +// handler and the option to include related resources. +type WithInclude[R any] struct { + parent Request[R] + include []string +} + +// Include returns a request that appends the named relationships to the +// request include list. +func (req WithInclude[R]) Include(relationships ...string) WithInclude[R] { + include := make([]string, 0, len(req.include)+len(relationships)) + include = append(include, req.include...) + include = append(include, relationships...) + req.include = include + return req +} + +// Do performs the request against the server and returns the result. +func (req WithInclude[R]) Do(ctx context.Context) (*R, error) { + + res, err := req.parent.do(ctx, includeParam.Value(req.include)) + if err != nil { + return nil, err + } + return res, nil +} diff --git a/jsonrpc/resource/request_save.go b/jsonrpc/resource/request_save.go deleted file mode 100644 index 628fc8c..0000000 --- a/jsonrpc/resource/request_save.go +++ /dev/null @@ -1,86 +0,0 @@ -// Copyright 2022 Searis AS -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package resource - -import ( - "context" - - "github.com/clarify/clarify-go/jsonrpc" -) - -const ( - paramCreateOnly jsonrpc.ParamName = "createOnly" -) - -// SaveMethod is a constructor for Requests against a given RPC method. -type SaveMethod[D, R any] struct { - APIVersion string - Method string - DataParam string -} - -func (cfg SaveMethod[D, R]) NewRequest(h jsonrpc.Handler, data D, params ...jsonrpc.Param) SaveRequest[D, R] { - return SaveRequest[D, R]{ - apiVersion: cfg.APIVersion, - method: cfg.Method, - dataParam: jsonrpc.ParamName(cfg.DataParam), - data: data, - baseParams: params, - h: h, - } -} - -// SaveRequest allows creating or updating properties based on a keyed -// relation. -type SaveRequest[D, R any] struct { - apiVersion string - method string - dataParam jsonrpc.ParamName - - baseParams []jsonrpc.Param - data D - createOnly bool - - h jsonrpc.Handler -} - -// CreateOnly returns a request with the createOnly property set to true. When -// set to true, existing resources are not updated. -func (req SaveRequest[D, R]) CreateOnly() SaveRequest[D, R] { - req.createOnly = true - return req -} - -// Do performs the request against the server and returns the result. -func (req SaveRequest[D, R]) Do(ctx context.Context, extraParams ...jsonrpc.Param) (*R, error) { - params := make([]jsonrpc.Param, 0, len(req.baseParams)+2+len(extraParams)) - params = append(params, req.baseParams...) - params = append(params, - req.dataParam.Value(req.data), - paramCreateOnly.Value(req.createOnly), - ) - params = append(params, extraParams...) - - rpcReq := jsonrpc.NewRequest(req.method, params...) - if req.apiVersion != "" { - rpcReq.APIVersion = req.apiVersion - } - - var res R - if err := req.h.Do(ctx, rpcReq, &res); err != nil { - return nil, err - } - return &res, nil -} diff --git a/jsonrpc/resource/request_select.go b/jsonrpc/resource/request_select.go deleted file mode 100644 index af2bf5f..0000000 --- a/jsonrpc/resource/request_select.go +++ /dev/null @@ -1,132 +0,0 @@ -// Copyright 2022-2023 Searis AS -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package resource - -import ( - "context" - - "github.com/clarify/clarify-go/jsonrpc" - "github.com/clarify/clarify-go/query" -) - -const ( - paramQuery jsonrpc.ParamName = "query" - paramInclude jsonrpc.ParamName = "include" - paramFormat jsonrpc.ParamName = "format" -) - -type SelectMethod[R any] struct { - APIVersion string - Method string -} - -func (cfg SelectMethod[R]) NewRequest(h jsonrpc.Handler, params ...jsonrpc.Param) SelectRequest[R] { - return SelectRequest[R]{ - apiVersion: cfg.APIVersion, - method: cfg.Method, - query: query.New(), - baseParams: params, - - h: h, - } -} - -type SelectRequest[R any] struct { - apiVersion string - method string - - baseParams []jsonrpc.Param - query query.Query - includes []string - - h jsonrpc.Handler -} - -// Query returns a new request that replace the internal query with the -// specified value. -func (req SelectRequest[R]) Query(q query.Query) SelectRequest[R] { - req.query = q - return req -} - -// Filter returns a new request with the specified filter added to existing -// filters with logical AND. -func (req SelectRequest[R]) Filter(f query.FilterType) SelectRequest[R] { - req.query.Filter = query.And(req.query.Filter, f) - return req -} - -// Limit returns a new request that limits the number of matches. Setting n < 0 -// will use the default limit. -func (req SelectRequest[R]) Limit(n int) SelectRequest[R] { - req.query.Limit = n - return req -} - -// Skip returns a new request that skips the first n matches. -func (req SelectRequest[R]) Skip(n int) SelectRequest[R] { - req.query.Skip = n - return req -} - -// Sort returns a new request that sorts according to the specified fields. A -// minus (-) prefix can be used on the filed name to indicate inverse ordering. -func (req SelectRequest[R]) Sort(fields ...string) SelectRequest[R] { - req.query.Sort = fields - return req -} - -// Total returns a new request that includes a total count of matches in the -// result. -func (req SelectRequest[R]) Total() SelectRequest[R] { - req.query.Total = true - return req -} - -// Include returns a new request that includes the specified related resources. -// the provided list is appended to any existing include properties. -func (req SelectRequest[R]) Include(relationships ...string) SelectRequest[R] { - a := make([]string, 0, len(req.includes)+len(relationships)) - a = append(a, req.includes...) - a = append(a, relationships...) - req.includes = a - return req -} - -// Do performs the request against the server and returns the result. -func (req SelectRequest[R]) Do(ctx context.Context, extraParams ...jsonrpc.Param) (*R, error) { - params := make([]jsonrpc.Param, 0, len(req.baseParams)+3+len(extraParams)) - params = append(params, req.baseParams...) - params = append(params, - paramQuery.Value(req.query), - paramInclude.Value(req.includes), - paramFormat.Value(SelectionFormat{ - DataAsArray: true, - GroupIncludedByType: true, - }), - ) - params = append(params, extraParams...) - - rpcReq := jsonrpc.NewRequest(req.method, params...) - if req.apiVersion != "" { - rpcReq.APIVersion = req.apiVersion - } - - var res R - if err := req.h.Do(ctx, rpcReq, &res); err != nil { - return nil, err - } - return &res, nil -} diff --git a/jsonrpc/resource/resource.go b/jsonrpc/resource/resource.go deleted file mode 100644 index 01e4ad3..0000000 --- a/jsonrpc/resource/resource.go +++ /dev/null @@ -1,130 +0,0 @@ -// Copyright 2022 Searis AS -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package resource - -import ( - "bytes" - "crypto/sha1" - "encoding/json" - "io" - "time" - - "github.com/clarify/clarify-go/fields" -) - -// Normalizer describes a type that should be normalized before encoding. -type Normalizer interface { - Normalize() -} - -// Resource describes a generic resource entry select view. -type Resource[A, R any] struct { - Identifier - Meta Meta `json:"meta"` - Attributes A `json:"attributes"` - Relationships R `json:"relationships"` -} - -var _ json.Marshaler = Resource[struct{}, struct{}]{} - -func (e Resource[A, R]) MarshalJSON() ([]byte, error) { - var target = struct { - Identifier - Meta Meta `json:"meta"` - Attributes json.RawMessage `json:"attributes"` - Relationships json.RawMessage `json:"relationships"` - }{ - Identifier: e.Identifier, - Meta: e.Meta, - } - - hash := sha1.New() - if n, ok := any(&e.Attributes).(Normalizer); ok { - n.Normalize() - } - var buf bytes.Buffer - enc := json.NewEncoder(io.MultiWriter(hash, &buf)) - if err := enc.Encode(e.Attributes); err != nil { - return nil, err - } - target.Attributes = buf.Bytes() - target.Meta.AttributesHash = fields.Hexadecimal(hash.Sum(nil)) - - hash = sha1.New() - if n, ok := any(&e.Attributes).(Normalizer); ok { - n.Normalize() - } - buf = bytes.Buffer{} - enc = json.NewEncoder(io.MultiWriter(hash, &buf)) - if err := enc.Encode(e.Relationships); err != nil { - return nil, err - } - target.Relationships = buf.Bytes() - target.Meta.RelationshipsHash = fields.Hexadecimal(hash.Sum(nil)) - - return json.Marshal(target) -} - -// ToOne describes a to one relationship entry. -type ToOne struct { - Meta map[string]json.RawMessage `json:"meta,omitempty"` - Data NullIdentifier `json:"data"` -} - -// ToMany describes a to many relationship entry. -type ToMany struct { - Meta map[string]json.RawMessage `json:"meta,omitempty"` - Data []Identifier `json:"data"` -} - -// Identifier uniquely identifies a resource entry. -type Identifier struct { - Type string `json:"type"` - ID string `json:"id"` -} - -// NullIdentifier is a version of Identifier where the zero-value is encoded as -// null in JSON. -type NullIdentifier Identifier - -var ( - _ json.Marshaler = NullIdentifier{} - _ json.Unmarshaler = (*NullIdentifier)(nil) -) - -func (id NullIdentifier) MarshalJSON() ([]byte, error) { - if id.ID == "" && id.Type == "" { - return []byte(`null`), nil - } - return json.Marshal(Identifier(id)) -} - -func (id *NullIdentifier) UnmarshalJSON(data []byte) error { - data = bytes.TrimSpace(data) - if bytes.Equal(data, []byte(`null`)) { - *id = NullIdentifier{} - return nil - } - return json.Unmarshal(data, (*Identifier)(id)) -} - -// Meta holds the meta data fields for a resource entry select view. -type Meta struct { - Annotations fields.Annotations `json:"annotations,omitempty"` - AttributesHash fields.Hexadecimal `json:"attributesHash,omitempty"` - RelationshipsHash fields.Hexadecimal `json:"relationshipsHash,omitempty"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` -} diff --git a/query/data_filter.go b/query/data_filter.go new file mode 100644 index 0000000..ff53b00 --- /dev/null +++ b/query/data_filter.go @@ -0,0 +1,128 @@ +// Copyright 2023 Searis AS +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package query + +import ( + "slices" + "time" +) + +// DataFilter describe a type can return an internal data filter structure. +// Data filters helps reduce the amount of data that is returned by a method. +type DataFilter interface { + filter() dataFilter +} + +// DataAnd joins one or more data filters with logical and. +func DataAnd(filters ...DataFilter) DataFilter { + var result dataFilter + + for _, ft := range filters { + f := ft.filter() + + // Use greatest non-zero times.$gte value. + switch { + case f.Times.GreaterOrEqual.IsZero(): + //pass + case result.Times.GreaterOrEqual.IsZero(), f.Times.GreaterOrEqual.After(result.Times.GreaterOrEqual): + result.Times.GreaterOrEqual = f.Times.GreaterOrEqual + } + + // Use least non-zero times.$lt value. + switch { + case f.Times.Less.IsZero(): + // pass + case result.Times.Less.IsZero(), f.Times.Less.Before(result.Times.Less): + // Use least value. + result.Times.Less = f.Times.Less + } + + // Use the union of non-zero series.$in values. + switch { + case f.Series.In == nil: + // pass + case result.Series.In == nil: + result.Series.In = f.Series.In + default: + sizeHint := len(result.Series.In) + if l := len(f.Series.In); l < sizeHint { + sizeHint = l + } + union := make([]string, 0, sizeHint) + for _, k := range result.Series.In { + if slices.Contains(f.Series.In, k) { + union = append(union, k) + } + } + result.Series.In = union + } + } + return result +} + +type dataFilter struct { + Times timesFilter `json:"times"` + Series seriesFilter `json:"series"` +} + +var _ DataFilter = dataFilter{} + +func (df dataFilter) filter() dataFilter { + return df +} + +type timesFilter struct { + GreaterOrEqual time.Time `json:"$gte,omitempty"` + Less time.Time `json:"$lt,omitempty"` +} + +// TimeRange return a TimesFilter that matches times in range [gte,lt). +// +// Be aware of API limits according to how large time ranges you can query with +// different query resolutions. In order to query larger time windows in a +// single query, you can increase the width of your rollup duration. +// +// See the API documentation for the method you are calling for more details: +// https://docs.clarify.io/api/1.1/. +func TimeRange(gte, lt time.Time) DataFilter { + return timesFilter{ + GreaterOrEqual: gte, + Less: lt, + } +} + +func (tf timesFilter) filter() dataFilter { + return dataFilter{ + Times: tf, + } +} + +type seriesFilter struct { + In []string `json:"$in,omitempty"` +} + +// SeriesIn return a data filter that reduce the time-series to encode in the +// final result to the ones the ones that are in the specified list of keys. +func SeriesIn(keys ...string) DataFilter { + return seriesFilter{ + In: keys, + } +} + +func (sf seriesFilter) filter() dataFilter { + return dataFilter{ + Series: sf, + } +} diff --git a/query/data_query.go b/query/data_query.go new file mode 100644 index 0000000..ee7bc6e --- /dev/null +++ b/query/data_query.go @@ -0,0 +1,133 @@ +// Copyright 2022-2023 Searis AS +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package query + +import ( + "encoding/json" + "time" + + "github.com/clarify/clarify-go/fields" +) + +type dataQuery struct { + Filter dataFilter `json:"filter"` + Rollup string `json:"rollup,omitempty"` + Last int `json:"last,omitempty"` + Origin string `json:"origin,omitempty"` + FirstDayOfWeek int `json:"firstDayOfWeek,omitempty"` + TimeZone string `json:"timeZone,omitempty"` +} + +// Data holds a data query. Although it does not expose any fields, the type +// can be decoded from and encoded to JSON. +type Data struct { + query dataQuery +} + +// DataWhere returns a new Data query that joins passed in filters with logical +// AND. +func DataWhere(filters ...DataFilter) Data { + return Data{ + query: dataQuery{ + Filter: DataAnd(filters...).filter(), + }, + } +} + +func (q *Data) UnmarshalJSON(data []byte) error { + return json.Unmarshal(data, &q.query) +} + +func (q Data) MarshalJSON() ([]byte, error) { + return json.Marshal(q.query) +} + +// Origin returns a new data query with a custom rollup bucket origin. The +// origin is used by DurationRollup and MonthRollup. This setting takes +// precedence over the firstDayOfWeek setting passed to DurationRollup. +func (dq Data) Origin(o time.Time) Data { + dq.query.Origin = o.Format(time.RFC3339Nano) + return dq +} + +// RollupWindow returns a new data query with a window based rollup. +func (dq Data) RollupWindow() Data { + dq.query.Rollup = "window" + return dq +} + +// RollupDuration returns a new data query with a fixed duration bucket rollup. +// +// The default bucket origin is set to time 00:00:00 according to the query +// time-zone for the first date in 2000 where the weekday matches the +// firstDayOfWeek parameter. +func (dq Data) RollupDuration(d time.Duration, firstDayOfWeek time.Weekday) Data { + dq.query.Rollup = fields.AsFixedDurationNullZero(d).String() + isoDay := int(firstDayOfWeek) % 7 + if isoDay == 0 { + isoDay = 7 + } + dq.query.FirstDayOfWeek = isoDay + return dq +} + +// RollupMonths returns a new data query with a calendar month bucket rollup. +// +// The default bucket origin is set to time 00:00:00 according to the query +// time-zone for January 1 year 2000. +func (dq Data) RollupMonths(months int) Data { + dq.query.Rollup = fields.CalendarDurationNullZero{Months: months}.String() + return dq +} + +// TimeZoneLocation returns a new data query with the time-zone set to TZ +// database name of the passed in loc. +// +// The method is equivalent to dq.TimeZone(loc.String()). +func (dq Data) TimeZoneLocation(loc *time.Location) Data { + dq.query.TimeZone = loc.String() // nil values return "UTC". + return dq +} + +// TimeZone returns a new data query with TimeZone set to name. The name should +// contain a valid TZ Database reference, such as "UTC", "Europe/Berlin" or +// "America/New_York". The default value is "UTC". +// +// See https://en.wikipedia.org/wiki/List_of_tz_database_time_zones for +// available values. +// +// The time zone of a data query affects how the rollup bucket origin is aligned +// when there is no custom origin provided. If the time-zone location includes +// daylight saving time adjustments (DST), then resulting bucket times are +// adjusted according to local clock times if the bucket width is above the DST +// adjustment offset (normally 1 hour). +func (dq Data) TimeZone(name string) Data { + dq.query.TimeZone = name + return dq +} + +// Where returns a new data query which joins the passed in filter conditions +// with existing filer conditions using logical and. +func (dq Data) Where(filter DataFilter) Data { + dq.query.Filter = DataAnd(dq.query.Filter, filter).filter() + return dq +} + +// Last returns a new data returns only the last n non-empty data-points per +// series. If n is <= 0 , we include all data points in the query. +func (dq Data) Last(n int) Data { + dq.query.Last = n + return dq +} diff --git a/query/filter.go b/query/filter.go deleted file mode 100644 index 362e310..0000000 --- a/query/filter.go +++ /dev/null @@ -1,167 +0,0 @@ -// Copyright 2022 Searis AS -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package query - -import ( - "encoding/json" - "fmt" - "strings" -) - -// FilterType describe any type that can generate a filter. -type FilterType interface { - Filter() Filter -} - -// Filter describes a search filter for matching resources. -type Filter struct { - And []Filter - Or []Filter - Paths Comparisons -} - -func (f Filter) Filter() Filter { return f } - -var ( - _ interface { - json.Marshaler - fmt.Stringer - FilterType - } = Filter{} - _ json.Unmarshaler = (*Filter)(nil) -) - -// Field returns a new filter comparing a single field path. -func Field(path string, cmp Comparison) Filter { - return Filter{Paths: Comparisons{path: cmp}} -} - -// And returns an new filter that merges the passed in filters with logical AND. -func And(filters ...FilterType) Filter { - newF := Filter{ - And: make([]Filter, 0, len(filters)), - } - for _, ft := range filters { - f := ft.Filter() - switch { - case len(f.Or) == 0 && len(f.Paths) == 0: - newF.And = append(newF.And, f.And...) - default: - newF.And = append(newF.And, f) - } - } - if len(newF.And) == 1 { - return newF.And[0] - } - return newF -} - -// Or returns an new filter that merges the passed in filters with logical OR. -func Or(filters ...FilterType) Filter { - newF := Filter{ - Or: make([]Filter, 0, len(filters)), - } - for _, ft := range filters { - f := ft.Filter() - switch { - case len(f.And) == 0 && len(f.Paths) == 0: - newF.Or = append(newF.Or, f.Or...) - default: - newF.Or = append(newF.Or, f) - } - } - if len(newF.Or) == 1 { - return newF.Or[0] - } - return newF -} - -func (f Filter) String() string { - b, _ := f.MarshalJSON() - return string(b) -} - -func (f Filter) MarshalJSON() ([]byte, error) { - m := make(map[string]json.RawMessage, 2+len(f.Paths)) - for k, v := range f.Paths { - if strings.HasPrefix(k, "$") { - return nil, fmt.Errorf("path %q: operator prefix ($) not allowed in path filters", k) - } - j, err := json.Marshal(v) - if err != nil { - return nil, fmt.Errorf("path %s: %v", k, err) - } - m[k] = j - } - if len(f.And) > 0 { - j, err := json.Marshal(f.And) - if err != nil { - return nil, fmt.Errorf("$and: %v", err) - } - m["$and"] = j - } - if len(f.Or) > 0 { - j, err := json.Marshal(f.And) - if err != nil { - return nil, fmt.Errorf("$or: %v", err) - } - m["$or"] = j - } - return json.Marshal(m) -} - -func (f *Filter) UnmarshalJSON(data []byte) error { - var m map[string]json.RawMessage - if err := json.Unmarshal(data, &m); err != nil { - return err - } - - if v, ok := m["$and"]; ok { - if err := json.Unmarshal(v, &f.And); err != nil { - return err - } - delete(m, "$and") - } - if v, ok := m["$or"]; ok { - if err := json.Unmarshal(v, &f.Or); err != nil { - return err - } - delete(m, "$or") - } - f.Paths = make(Comparisons, len(m)) - for k, v := range m { - var cmp Comparison - if len(k) > 0 && k[0] == '$' { - return fmt.Errorf("bad conjunction %q", k) - } - if err := json.Unmarshal(v, &cmp); err != nil { - return err - } - f.Paths[k] = cmp - } - - // Minor optimization: simplify and/or clauses with only one entry. - switch { - case len(f.Paths) == 0 && len(f.Or) == 0 && len(f.And) == 1: - f.Paths = f.And[0].Paths - f.Or = f.And[0].Or - f.And = nil - case len(f.Paths) == 0 && len(f.Or) == 1 && len(f.And) == 0: - f.Paths = f.Or[0].Paths - f.And = f.Or[0].And - f.Or = nil - } - return nil -} diff --git a/query/query.go b/query/query.go deleted file mode 100644 index 29b3711..0000000 --- a/query/query.go +++ /dev/null @@ -1,60 +0,0 @@ -// Copyright 2022 Searis AS -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package query - -import "time" - -// Query describes the resource query structure. -type Query struct { - Filter Filter `json:"filter,omitempty"` - Sort []string `json:"sort,omitempty"` - Limit int `json:"limit"` - Skip int `json:"skip"` - Total bool `json:"total"` -} - -// New returns a new resource query, using the API default limit. -func New() Query { - return Query{Limit: -1} -} - -// Data describes a data frame query structure. -type Data struct { - Filter DataFilter `json:"filter"` - Rollup string `json:"rollup"` - Last int `json:"last,omitempty"` -} - -// DataFilter allows filtering which data to include in a data frame. The filter -// follows a similar structure to a resource Filter, but is more limited, and -// does not allow combining filters with "$and" or "$or" conjunctions. -type DataFilter struct { - Times DataTimesComparison `json:"times"` -} - -// DataTimesComparison allows filtering times. The zero-value indicate API -// defaults. -type DataTimesComparison struct { - GreaterOrEqual time.Time `json:"$gte,omitempty"` - Less time.Time `json:"$lt,omitempty"` -} - -// DataTimesRange matches times within the specified range. -func DataTimesRange(gte, lt time.Time) DataTimesComparison { - return DataTimesComparison{ - GreaterOrEqual: gte, - Less: lt, - } -} diff --git a/query/comparison.go b/query/resource_comparison.go similarity index 98% rename from query/comparison.go rename to query/resource_comparison.go index fda9fb6..e799fdf 100644 --- a/query/comparison.go +++ b/query/resource_comparison.go @@ -24,10 +24,10 @@ import ( // Comparisons maps field paths joined by dot to a comparison. type Comparisons map[string]Comparison -var _ FilterType = Comparisons{} +var _ Filter = Comparisons{} -func (paths Comparisons) Filter() Filter { - return Filter{Paths: paths} +func (paths Comparisons) filter() filter { + return filter{paths: paths} } // Comparison allows comparing a particular value with one or more operators. diff --git a/query/resource_filter.go b/query/resource_filter.go new file mode 100644 index 0000000..c9686ec --- /dev/null +++ b/query/resource_filter.go @@ -0,0 +1,189 @@ +// Copyright 2022-2023 Searis AS +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package query + +import ( + "encoding/json" + "fmt" + "strings" +) + +// Filter describe a type can return an internal resource filter structure. +// Filters reduce which resources. +type Filter interface { + filter() filter +} + +// And returns an new filter that merges the passed in filters with logical AND. +func And(filters ...Filter) Filter { + newF := filter{ + and: make([]filter, 0, len(filters)), + } + for _, ft := range filters { + f := ft.filter() + switch { + case len(f.or) == 0 && len(f.paths) == 0: + // Flatten and values (and skip empty queries). + newF.and = append(newF.and, f.and...) + default: + newF.and = append(newF.and, f) + } + } + if len(newF.and) == 1 { + return newF.and[0] + } + return newF +} + +// Or returns an new filter that merges the passed in filters with logical OR. +// Explicit nil values are omitted. +func Or(filters ...Filter) Filter { + newF := filter{ + or: make([]filter, 0, len(filters)), + } + + for _, ft := range filters { + + f := ft.filter() + if f.matchAll() { + // Optimization: + // OR(matchAll,matchSome) == matchAll + return filter{} + } + switch { + case len(f.and) == 0 && len(f.paths) == 0: + // Flatten OR values (but only when the query is not empty). + newF.or = append(newF.or, f.or...) + default: + newF.or = append(newF.or, f) + } + } + if len(newF.or) == 1 { + return newF.or[0] + } + return newF +} + +// filter describes a search filter for matching resources. +type filter struct { + and []filter + or []filter + paths Comparisons +} + +// All returns an empty filter, meaning it match all resources. +func All() filter { + return filter{} +} + +// matchAll return true if the filter matches all resources. A.k.a. the +// filter is empty. +func (f filter) matchAll() bool { + return len(f.and) == 0 && len(f.or) == 0 && len(f.paths) == 0 +} + +func (f filter) filter() filter { return f } + +var ( + _ interface { + json.Marshaler + fmt.Stringer + Filter + } = filter{} + _ json.Unmarshaler = (*filter)(nil) +) + +// Field returns a new filter comparing a single field path. +func Field(path string, cmp Comparison) filter { + return filter{paths: Comparisons{path: cmp}} +} + +func (f filter) String() string { + b, _ := f.MarshalJSON() + return string(b) +} + +func (f filter) MarshalJSON() ([]byte, error) { + m := make(map[string]json.RawMessage, 2+len(f.paths)) + for k, v := range f.paths { + if strings.HasPrefix(k, "$") { + return nil, fmt.Errorf("path %q: operator prefix ($) not allowed in path filters", k) + } + j, err := json.Marshal(v) + if err != nil { + return nil, fmt.Errorf("path %s: %v", k, err) + } + m[k] = j + } + if len(f.and) > 0 { + j, err := json.Marshal(f.and) + if err != nil { + return nil, fmt.Errorf("$and: %v", err) + } + m["$and"] = j + } + if len(f.or) > 0 { + j, err := json.Marshal(f.and) + if err != nil { + return nil, fmt.Errorf("$or: %v", err) + } + m["$or"] = j + } + return json.Marshal(m) +} + +func (f *filter) UnmarshalJSON(data []byte) error { + var m map[string]json.RawMessage + if err := json.Unmarshal(data, &m); err != nil { + return err + } + + if v, ok := m["$and"]; ok { + if err := json.Unmarshal(v, &f.and); err != nil { + return err + } + delete(m, "$and") + } + if v, ok := m["$or"]; ok { + if err := json.Unmarshal(v, &f.or); err != nil { + return err + } + delete(m, "$or") + } + f.paths = make(Comparisons, len(m)) + for k, v := range m { + var cmp Comparison + if len(k) > 0 && k[0] == '$' { + return fmt.Errorf("bad conjunction %q", k) + } + if err := json.Unmarshal(v, &cmp); err != nil { + return err + } + f.paths[k] = cmp + } + + // Minor optimization: simplify and/or clauses with only one entry. + switch { + case len(f.paths) == 0 && len(f.or) == 0 && len(f.and) == 1: + f.paths = f.and[0].paths + f.or = f.and[0].or + f.and = nil + case len(f.paths) == 0 && len(f.or) == 1 && len(f.and) == 0: + f.paths = f.or[0].paths + f.and = f.or[0].and + f.or = nil + } + return nil +} diff --git a/query/filter_test.go b/query/resource_filter_test.go similarity index 56% rename from query/filter_test.go rename to query/resource_filter_test.go index 7debf2f..1047590 100644 --- a/query/filter_test.go +++ b/query/resource_filter_test.go @@ -1,4 +1,4 @@ -// Copyright 2022 Searis AS +// Copyright 2022-2023 Searis AS // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -15,6 +15,7 @@ package query_test import ( + "fmt" "testing" "github.com/clarify/clarify-go/query" @@ -24,7 +25,7 @@ func TestFilter(t *testing.T) { testStringer := func(f query.Filter, expect string) func(t *testing.T) { return func(t *testing.T) { t.Helper() - if result := f.String(); result != expect { + if result := fmt.Sprint(f); result != expect { t.Errorf("unexpected query.String() value:\n got: %s\nwant: %s", result, expect, @@ -33,20 +34,20 @@ func TestFilter(t *testing.T) { } } - t.Run(`query.Filter{}`, testStringer( - query.Filter{}, + t.Run(`queryAll()`, testStringer( + query.All(), `{}`, )) - t.Run(`query.And(query.Filter{},query.Field("id",query.Equal("a")))`, testStringer( - query.And(query.Filter{}, query.Field("id", query.Equal("a"))), - `{"id":{"$in":["a"]}}`, + t.Run(`query.And(query.All(),query.Field("id",query.Equal("a")))`, testStringer( + query.And(query.All(), query.Field("id", query.Equal("a"))), + `{"id":{"$in":["a"]}}`, // Optimized to skip All query. )) - t.Run(`query.And(query.Filter{},query.Field("id",query.In("a")))`, testStringer( - query.And(query.Filter{}, query.Field("id", query.In("a"))), - `{"id":{"$in":["a"]}}`, + t.Run(`query.And(query.All(),query.Field("id",query.In("a","b")))`, testStringer( + query.And(query.All(), query.Field("id", query.In("a", "b"))), + `{"id":{"$in":["a","b"]}}`, // Optimized to skip All query. )) - t.Run(`query.And(query.Filter{},query.Field("id",query.In("a","b")))`, testStringer( - query.And(query.Filter{}, query.Field("id", query.In("a", "b"))), - `{"id":{"$in":["a","b"]}}`, + t.Run(`query.Or(query.All(),query.Field("id",query.Equal("a")))`, testStringer( + query.Or(query.All(), query.Field("id", query.Equal("a"))), + `{}`, // Optimized to empty query (match all). )) } diff --git a/query/resource_query.go b/query/resource_query.go new file mode 100644 index 0000000..7cceaab --- /dev/null +++ b/query/resource_query.go @@ -0,0 +1,123 @@ +// Copyright 2022-2023 Searis AS +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package query + +import ( + "encoding/json" +) + +const defaultQueryLimit = 50 + +type resourceQuery struct { + Filter filter `json:"filter,omitempty"` + Sort []string `json:"sort,omitempty"` + Limit int `json:"limit"` + Skip int `json:"skip"` + Total bool `json:"total"` +} + +// Query holds a resource query. Although it does not expose any fields, the +// type can be decoded from and encoded to JSON. +type Query struct { + limitSet bool + query resourceQuery +} + +// Where returns a new query which joins the passed in filters with logical AND. +func Where(filters ...Filter) Query { + return Query{ + query: resourceQuery{ + Filter: And(filters...).filter(), + }, + } +} + +func (q *Query) UnmarshalJSON(data []byte) error { + q.limitSet = true + q.query = resourceQuery{Limit: defaultQueryLimit} + return json.Unmarshal(data, &q.query) +} + +func (q Query) MarshalJSON() ([]byte, error) { + if !q.limitSet { + q.query.Limit = defaultQueryLimit + } + return json.Marshal(q.query) +} + +// Where returns a new query with the given where condition added to existing +// query non-empty filters with logical AND. +// +// See the API reference documentation to determine which fields are filterable +// for each resource: https://docs.clarify.io/api/1.1/types/resources. +func (q Query) Where(filter Filter) Query { + q.query.Filter = And(q.query.Filter, filter).filter() + return q +} + +// Sort returns a new query that sorts results using the provided fields. To get +// descending sort, prefix the field with a minus (-). +// +// See the API reference documentation to determine which fields are sortable +// for each resource: https://docs.clarify.io/api/1.1/types/resources. +func (q Query) Sort(fields ...string) Query { + sort := make([]string, 0, len(q.query.Sort)+len(fields)) + sort = append(sort, q.query.Sort...) + sort = append(sort, fields...) + q.query.Sort = sort + return q +} + +// Skip returns a query that skips the first n entries matching the query. +func (q Query) Skip(n int) Query { + q.query.Skip = n + return q +} + +// GetSkip returns the query skip value. +func (q Query) GetSkip() int { + return q.query.Skip +} + +// Limit returns a new query that limits the number of results to n. Set limit +// to -1 to use the maximum allowed value. +func (q Query) Limit(n int) Query { + q.limitSet = true + q.query.Limit = n + return q +} + +// GetLimit returns the query limit value. +func (q Query) GetLimit() int { + if !q.limitSet { + return defaultQueryLimit + } + return q.query.Limit +} + +// NextPage returns a nre query where the skip value is incremented by the +// queries limit value. +func (q Query) NextPage() Query { + q.query.Skip += q.GetLimit() + return q +} + +// Total returns a query that forces the inclusion of a total count in the +// response when force is true, or includes it only if it can be calculated for +// free if force is false. +func (q Query) Total(force bool) Query { + q.query.Total = force + return q +} diff --git a/resource_data_frame.go b/resource_data_frame.go deleted file mode 100644 index c794ab4..0000000 --- a/resource_data_frame.go +++ /dev/null @@ -1,126 +0,0 @@ -// Copyright 2022 Searis AS -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package clarify - -import ( - "context" - "time" - - "github.com/clarify/clarify-go/fields" - "github.com/clarify/clarify-go/jsonrpc/resource" - "github.com/clarify/clarify-go/query" - "github.com/clarify/clarify-go/views" -) - -// DataFrameRequest describes a data frame request. -type DataFrameRequest struct { - parent resource.SelectRequest[DataFrameResult] - data query.Data -} - -type DataFrameResult = struct { - Meta resource.SelectionMeta `json:"meta"` - Data views.DataFrame `json:"data"` - Included DataFrameInclude `json:"included"` -} - -// DataFrameInclude describe the included properties for the dataFrame select -// view. -type DataFrameInclude struct { - Items []views.Item `json:"items"` -} - -// Query returns a new request that replace the internal query with the -// specified value. -func (req DataFrameRequest) Query(q query.Query) DataFrameRequest { - req.parent = req.parent.Query(q) - return req -} - -// Filter returns a new request that includes Items matching the provided -// filter. -func (req DataFrameRequest) Filter(filter query.FilterType) DataFrameRequest { - req.parent = req.parent.Filter(filter) - return req -} - -// Limit returns a new request that limits the number of matches. Setting n < 0 -// will use the max limit. -func (req DataFrameRequest) Limit(n int) DataFrameRequest { - req.parent = req.parent.Limit(n) - return req -} - -// Skip returns a new request that skips the first n matches. -func (req DataFrameRequest) Skip(n int) DataFrameRequest { - req.parent = req.parent.Skip(n) - return req -} - -// Sort returns a new request that sorts according to the specified fields. A -// minus (-) prefix can be used on the filed name to indicate inverse ordering. -func (req DataFrameRequest) Sort(fields ...string) DataFrameRequest { - req.parent = req.parent.Sort(fields...) - return req -} - -// Total returns a new request that includes a total count of matches in the -// result. -func (req DataFrameRequest) Total() DataFrameRequest { - req.parent = req.parent.Total() - return req -} - -// Include returns a new request set to include the specified relationships. -func (req DataFrameRequest) Include(relationships ...string) DataFrameRequest { - req.parent = req.parent.Include(relationships...) - return req -} - -// TimeRange returns a new request that include data for matching items in the -// specified time range. Note that including data will reduce the maximum number -// of items that can be returned by the response. -func (req DataFrameRequest) TimeRange(gte, lt time.Time) DataFrameRequest { - req.data.Filter.Times = query.DataTimesRange(gte, lt) - return req -} - -// RollupWindow sets the query to rollup all data into a single timestamp. -func (req DataFrameRequest) RollupWindow() DataFrameRequest { - req.data.Rollup = "window" - return req -} - -// RollupBucket sets the query to rollup all data into fixed size bucket -// when d > 0. Otherwise, clear the rollup information from the query. -func (req DataFrameRequest) RollupBucket(d time.Duration) DataFrameRequest { - if d > 0 { - req.data.Rollup = fields.AsFixedDuration(d).String() - } else { - req.data.Rollup = "" - } - return req -} - -// Last sets the query to only include the n last matching values per item. -func (req DataFrameRequest) Last(n int) DataFrameRequest { - req.data.Last = n - return req -} - -// Do performs the request against the server and returns the result. -func (req DataFrameRequest) Do(ctx context.Context) (*DataFrameResult, error) { - return req.parent.Do(ctx, paramData.Value(req.data)) -} diff --git a/views/data_frame.go b/views/data_frame.go index c80de4e..8fa18d7 100644 --- a/views/data_frame.go +++ b/views/data_frame.go @@ -31,6 +31,10 @@ var ( _ sort.Interface = rawDataFrame{} ) +type DataFrameInclude struct { + Items []Item +} + // DataSeries contain a map of timestamps in micro seconds since the epoch to // a floating point value. type DataSeries map[fields.Timestamp]float64 diff --git a/views/item.go b/views/item.go index 1bcee5d..99cc385 100644 --- a/views/item.go +++ b/views/item.go @@ -16,11 +16,10 @@ package views import ( "github.com/clarify/clarify-go/fields" - "github.com/clarify/clarify-go/jsonrpc/resource" ) // Item describe the select view for an item. -type Item = resource.Resource[ItemAttributes, ItemRelationships] +type Item = Resource[ItemAttributes, ItemRelationships] type ItemInclude struct{} @@ -62,21 +61,21 @@ type ItemAttributes struct { // ItemSaveAttributes contains attributes that are part of the item save view. type ItemSaveAttributes struct { - Name string `json:"name"` - Description string `json:"description"` - ValueType ValueType `json:"valueType"` - SourceType SourceType `json:"sourceType"` - EngUnit string `json:"engUnit"` - SampleInterval fields.FixedDuration `json:"sampleInterval"` - GapDetection fields.FixedDuration `json:"gapDetection"` - Labels fields.Labels `json:"labels"` - EnumValues fields.EnumValues `json:"enumValues"` - Visible bool `json:"visible"` + Name string `json:"name"` + Description string `json:"description"` + ValueType ValueType `json:"valueType"` + SourceType SourceType `json:"sourceType"` + EngUnit string `json:"engUnit"` + SampleInterval fields.FixedDurationNullZero `json:"sampleInterval"` + GapDetection fields.FixedDurationNullZero `json:"gapDetection"` + Labels fields.Labels `json:"labels"` + EnumValues fields.EnumValues `json:"enumValues"` + Visible bool `json:"visible"` } // ItemRelationships describe the item relationships that's exposed by the API. type ItemRelationships struct { - CreatedBy resource.ToOne `json:"createdBy"` - UpdatedBy resource.ToOne `json:"updatedBy"` - Organization resource.ToOne `json:"organization"` + CreatedBy ToOne `json:"createdBy"` + UpdatedBy ToOne `json:"updatedBy"` + Organization ToOne `json:"organization"` } diff --git a/views/item_test.go b/views/item_test.go index 62a3ffd..31d70c2 100644 --- a/views/item_test.go +++ b/views/item_test.go @@ -1,4 +1,4 @@ -// Copyright 2022 Searis AS +// Copyright 2022-2023 Searis AS // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -19,7 +19,6 @@ import ( "testing" "time" - "github.com/clarify/clarify-go/jsonrpc/resource" "github.com/clarify/clarify-go/views" ) @@ -28,11 +27,11 @@ func TestItemSelectMarshalJSON(t *testing.T) { now := time.Now() expectSHA1 := "cc65e86b937c9d743a2b004b4f5de9ccde46bfc8" item := views.Item{ - Identifier: resource.Identifier{ + Identifier: views.Identifier{ Type: "items", ID: itemID, }, - Meta: resource.Meta{ + Meta: views.Meta{ CreatedAt: now, UpdatedAt: now, }, @@ -47,7 +46,7 @@ func TestItemSelectMarshalJSON(t *testing.T) { t.Errorf("json.Marshal returns an error: %v", err) } var check struct { - Meta resource.Meta + Meta views.Meta } if err := json.Unmarshal(data, &check); err != nil { t.Errorf("json.Unmarshal returns an error: %v", err) diff --git a/views/resource.go b/views/resource.go index 6d877ba..900191d 100644 --- a/views/resource.go +++ b/views/resource.go @@ -1,4 +1,4 @@ -// Copyright 2022 Searis AS +// Copyright 2022-2023 Searis AS // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -14,9 +14,122 @@ package views -import "github.com/clarify/clarify-go/fields" +import ( + "bytes" + "crypto/sha1" + "encoding/json" + "io" + "time" + + "github.com/clarify/clarify-go/fields" +) // MetaSave holds the mutable meta fields for a resource entry. type MetaSave struct { Annotations fields.Annotations `json:"annotations,omitempty"` } + +// Normalizer describes a type that should be normalized before encoding. +type Normalizer interface { + Normalize() +} + +// Resource describes a generic resource entry select view. +type Resource[A, R any] struct { + Identifier + Meta Meta `json:"meta"` + Attributes A `json:"attributes"` + Relationships R `json:"relationships"` +} + +var _ json.Marshaler = Resource[struct{}, struct{}]{} + +func (e Resource[A, R]) MarshalJSON() ([]byte, error) { + var target = struct { + Identifier + Meta Meta `json:"meta"` + Attributes json.RawMessage `json:"attributes"` + Relationships json.RawMessage `json:"relationships"` + }{ + Identifier: e.Identifier, + Meta: e.Meta, + } + + hash := sha1.New() + if n, ok := any(&e.Attributes).(Normalizer); ok { + n.Normalize() + } + var buf bytes.Buffer + enc := json.NewEncoder(io.MultiWriter(hash, &buf)) + if err := enc.Encode(e.Attributes); err != nil { + return nil, err + } + target.Attributes = buf.Bytes() + target.Meta.AttributesHash = fields.Hexadecimal(hash.Sum(nil)) + + hash = sha1.New() + if n, ok := any(&e.Attributes).(Normalizer); ok { + n.Normalize() + } + buf = bytes.Buffer{} + enc = json.NewEncoder(io.MultiWriter(hash, &buf)) + if err := enc.Encode(e.Relationships); err != nil { + return nil, err + } + target.Relationships = buf.Bytes() + target.Meta.RelationshipsHash = fields.Hexadecimal(hash.Sum(nil)) + + return json.Marshal(target) +} + +// ToOne describes a to one relationship entry. +type ToOne struct { + Meta map[string]json.RawMessage `json:"meta,omitempty"` + Data NullIdentifier `json:"data"` +} + +// ToMany describes a to many relationship entry. +type ToMany struct { + Meta map[string]json.RawMessage `json:"meta,omitempty"` + Data []Identifier `json:"data"` +} + +// Identifier uniquely identifies a resource entry. +type Identifier struct { + Type string `json:"type"` + ID string `json:"id"` +} + +// NullIdentifier is a version of Identifier where the zero-value is encoded as +// null in JSON. +type NullIdentifier Identifier + +var ( + _ json.Marshaler = NullIdentifier{} + _ json.Unmarshaler = (*NullIdentifier)(nil) +) + +func (id NullIdentifier) MarshalJSON() ([]byte, error) { + if id.ID == "" && id.Type == "" { + return []byte(`null`), nil + } + return json.Marshal(Identifier(id)) +} + +func (id *NullIdentifier) UnmarshalJSON(data []byte) error { + data = bytes.TrimSpace(data) + if bytes.Equal(data, []byte(`null`)) { + *id = NullIdentifier{} + return nil + } + return json.Unmarshal(data, (*Identifier)(id)) +} + +// Meta holds the meta data fields for a resource entry select view. +type Meta struct { + Annotations fields.Annotations `json:"annotations,omitempty"` + AttributesHash fields.Hexadecimal `json:"attributesHash,omitempty"` + RelationshipsHash fields.Hexadecimal `json:"relationshipsHash,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} diff --git a/jsonrpc/resource/response.go b/views/response.go similarity index 94% rename from jsonrpc/resource/response.go rename to views/response.go index 0d87316..2480329 100644 --- a/jsonrpc/resource/response.go +++ b/views/response.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package resource +package views // SaveSummary holds the identity of a resource, and describes weather it was // updated or created due to your operation. @@ -30,9 +30,9 @@ type CreateSummary struct { } // Selection holds resource selection results. -type Selection[E, I any] struct { +type Selection[D, I any] struct { Meta SelectionMeta `json:"meta"` - Data []E `json:"data"` + Data D `json:"data"` Included I `json:"included"` } diff --git a/views/signal.go b/views/signal.go index 02adf15..a6c7bcc 100644 --- a/views/signal.go +++ b/views/signal.go @@ -1,4 +1,4 @@ -// Copyright 2022 Searis AS +// Copyright 2022-2023 Searis AS // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -16,11 +16,10 @@ package views import ( "github.com/clarify/clarify-go/fields" - "github.com/clarify/clarify-go/jsonrpc/resource" ) // Signal describe the select view for a signal. -type Signal = resource.Resource[SignalAttributes, SignalRelationships] +type Signal = Resource[SignalAttributes, SignalRelationships] type SignalInclude struct { Items []Item `json:"items"` @@ -46,21 +45,21 @@ type SignalReadOnlyAttributes struct { // SignalSaveAttributes contains attributes that are part of the signal save // view. type SignalSaveAttributes struct { - Name string `json:"name"` - Description string `json:"description"` - ValueType ValueType `json:"valueType"` - SourceType SourceType `json:"sourceType"` - EngUnit string `json:"engUnit"` - SampleInterval fields.FixedDuration `json:"sampleInterval"` - GapDetection fields.FixedDuration `json:"gapDetection"` - Labels fields.Labels `json:"labels"` - EnumValues fields.EnumValues `json:"enumValues"` + Name string `json:"name"` + Description string `json:"description"` + ValueType ValueType `json:"valueType"` + SourceType SourceType `json:"sourceType"` + EngUnit string `json:"engUnit"` + SampleInterval fields.FixedDurationNullZero `json:"sampleInterval"` + GapDetection fields.FixedDurationNullZero `json:"gapDetection"` + Labels fields.Labels `json:"labels"` + EnumValues fields.EnumValues `json:"enumValues"` } // SignalRelationships declare the available relationships for the signal model. type SignalRelationships struct { - Integration resource.ToOne `json:"integration"` - Item resource.ToOne `json:"item"` + Integration ToOne `json:"integration"` + Item ToOne `json:"item"` } // ValueType determine how data values should be interpreted.