Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 56 additions & 64 deletions package-lock.json

Large diffs are not rendered by default.

10 changes: 7 additions & 3 deletions pkg/plugin/cubeclient.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,9 +162,11 @@ type CubeAnnotation struct {

// CubeFieldInfo represents the metadata for a field
type CubeFieldInfo struct {
Title string `json:"title"`
ShortTitle string `json:"shortTitle"`
Type string `json:"type"`
Title string `json:"title"`
ShortTitle string `json:"shortTitle"`
Type string `json:"type"`
Format CubeFormat `json:"format,omitempty"`
Currency string `json:"currency,omitempty"`
}

// fetchCubeMetadata fetches metadata from Cube's /v1/meta endpoint
Expand Down Expand Up @@ -249,4 +251,6 @@ type CubeMeasure struct {
Type string `json:"type"`
ShortTitle string `json:"shortTitle"`
Description string `json:"description"`
Format CubeFormat `json:"format,omitempty"`
Currency string `json:"currency,omitempty"`
}
164 changes: 164 additions & 0 deletions pkg/plugin/format.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
package plugin

import (
"encoding/json"
"strings"
)

// CubeFormat accepts Cube meta/query format as either a string (e.g. "currency")
// or an object (e.g. {"type":"custom-numeric","value":",.0f","alias":"number_0"}).
type CubeFormat string

func (f *CubeFormat) UnmarshalJSON(data []byte) error {
if len(data) == 0 || string(data) == "null" {
*f = ""
return nil
}

if data[0] == '"' {
var s string
if err := json.Unmarshal(data, &s); err != nil {
return err
}
*f = CubeFormat(s)
return nil
}

var obj struct {
Type string `json:"type"`
Value string `json:"value"`
Alias string `json:"alias"`
}
if err := json.Unmarshal(data, &obj); err != nil {
return err
}

switch {
case obj.Alias != "":
*f = CubeFormat(obj.Alias)
case obj.Value != "":
*f = CubeFormat(obj.Value)
default:
*f = ""
}
return nil
}

func (f CubeFormat) String() string {
return string(f)
}

// cubeFormatToGrafanaUnit maps Cube measure/dimension format values to Grafana field units.
//
// Cube format docs: https://docs.cube.dev/reference/data-modeling/measures#format
// Grafana units list: https://github.com/grafana/grafana/blob/main/packages/grafana-data/src/valueFormats/categories.ts
//
// Cube named formats accept an optional "_N" suffix (0–6) for decimal precision
// (e.g. "currency_2"). Custom d3-format specifier strings are also supported
// (e.g. "$,.2f", ".0%").
func cubeFormatToGrafanaUnit(format, currency string) string {
if format == "" {
// A currency code without a format still implies a currency value.
if currency != "" {
return cubeCurrencyUnit(currency)
}
return ""
}

switch cubeFormatBaseName(format) {
case "percent":
// Cube percent formats display 0.125 as 12.5%, matching Grafana's
// percentunit (Percent 0.0-1.0) rather than percent (0-100).
return "percentunit"
case "currency":
return cubeCurrencyUnit(currency)
case "accounting":
// Accounting is "parentheses for negatives". When paired with a
// currency code, treat as currency; otherwise leave unset.
if currency != "" {
return cubeCurrencyUnit(currency)
}
return ""
case "abbr":
// SI prefix (K, M, G, …) -> Grafana "short" (K, Mil, Bil, …).
return "short"
case "number", "decimal", "id":
return ""
default:
// Custom d3-format specifiers.
if strings.Contains(format, "%") {
return "percentunit"
}
if strings.Contains(format, "$") || currency != "" {
return cubeCurrencyUnit(currency)
}
return ""
}
}

// cubeFormatBaseName strips the optional "_N" precision suffix (N is 0–6).
func cubeFormatBaseName(format string) string {
if idx := strings.LastIndex(format, "_"); idx > 0 {
suffix := format[idx+1:]
if len(suffix) == 1 && suffix[0] >= '0' && suffix[0] <= '6' {
return format[:idx]
}
}
return format
}

// grafanaBuiltInCurrencyUnits lists the ISO 4217 codes that Grafana ships
// dedicated currencyXXX value formats for. Using these yields proper symbols
// and locale-aware rendering. Codes not in this map fall back to the generic
// `currency:XXX` custom-unit syntax.
//
// Source: packages/grafana-data/src/valueFormats/categories.ts
var grafanaBuiltInCurrencyUnits = map[string]string{
"USD": "currencyUSD",
"GBP": "currencyGBP",
"EUR": "currencyEUR",
"JPY": "currencyJPY",
"RUB": "currencyRUB",
"UAH": "currencyUAH",
"BRL": "currencyBRL",
"DKK": "currencyDKK",
"ISK": "currencyISK",
"NOK": "currencyNOK",
"SEK": "currencySEK",
"CZK": "currencyCZK",
"CHF": "currencyCHF",
"PLN": "currencyPLN",
"BTC": "currencyBTC",
"ZAR": "currencyZAR",
"INR": "currencyINR",
"KRW": "currencyKRW",
"IDR": "currencyIDR",
"PHP": "currencyPHP",
"VND": "currencyVND",
"TRY": "currencyTRY",
"MYR": "currencyMYR",
"XPF": "currencyXPF",
"BGN": "currencyBGN",
"PYG": "currencyPYG",
"UYU": "currencyUYU",
"ILS": "currencyILS",
}

// cubeCurrencyUnit maps an ISO 4217 currency code to the best matching
// Grafana unit. Falls back to Grafana's custom `currency:XXX` syntax for
// codes without a dedicated built-in format, and to currencyUSD when no
// currency was provided (USD is Grafana's conventional default).
func cubeCurrencyUnit(currency string) string {
if currency == "" {
return "currencyUSD"
}
code := strings.ToUpper(strings.TrimSpace(currency))
if unit, ok := grafanaBuiltInCurrencyUnits[code]; ok {
return unit
}
return "currency:" + code
}

func fieldInfoUnit(info CubeFieldInfo) string {
return cubeFormatToGrafanaUnit(info.Format.String(), info.Currency)
}
134 changes: 134 additions & 0 deletions pkg/plugin/format_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
package plugin

import (
"encoding/json"
"testing"
)

func TestCubeFormatToGrafanaUnit(t *testing.T) {
tests := []struct {
name string
format string
currency string
want string
}{
// No format / no currency -> no unit.
{"empty", "", "", ""},

// Currency code alone is enough to render as currency.
{"currency code only USD", "", "USD", "currencyUSD"},
{"currency code only NZD", "", "NZD", "currency:NZD"},

// Percent formats (Cube's percent values are unit fractions, 0–1).
{"percent", "percent", "", "percentunit"},
{"percent_2", "percent_2", "", "percentunit"},
{"percent_0", "percent_0", "", "percentunit"},

// Built-in currencies use Grafana's dedicated units.
{"currency default USD", "currency", "", "currencyUSD"},
{"currency USD", "currency", "USD", "currencyUSD"},
{"currency EUR", "currency", "EUR", "currencyEUR"},
{"currency GBP", "currency_0", "GBP", "currencyGBP"},
{"currency JPY", "currency_2", "JPY", "currencyJPY"},
{"currency BRL lowercase", "currency", "brl", "currencyBRL"},
{"currency CHF", "currency", "CHF", "currencyCHF"},
{"currency INR", "currency", "INR", "currencyINR"},

// Unknown ISO codes fall back to Grafana's custom-unit syntax.
{"currency CAD fallback", "currency", "CAD", "currency:CAD"},
{"currency AUD fallback", "currency_2", "AUD", "currency:AUD"},
{"currency NZD trim", "currency", " nzd ", "currency:NZD"},

// Accounting is currency only when a currency code is provided.
{"accounting no currency", "accounting", "", ""},
{"accounting EUR", "accounting_2", "EUR", "currencyEUR"},

// SI abbreviation maps to Grafana short.
{"abbr", "abbr", "", "short"},
{"abbr_1", "abbr_1", "", "short"},

// Plain numeric formats have no unit.
{"number", "number", "", ""},
{"number_2", "number_2", "", ""},
{"decimal", "decimal", "", ""},
{"id", "id", "", ""},

// Custom d3-format specifiers.
{"d3 percent", ".0%", "", "percentunit"},
{"d3 dollar USD", "$,.2f", "USD", "currencyUSD"},
{"d3 dollar default USD", "$,.2f", "", "currencyUSD"},
{"d3 dollar EUR", "$,.2f", "EUR", "currencyEUR"},
{"d3 specifier with currency only", ".2f", "GBP", "currencyGBP"},
{"d3 specifier without hints", ".2f", "", ""},
{"unknown format", "custom", "", ""},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := cubeFormatToGrafanaUnit(tt.format, tt.currency)
if got != tt.want {
t.Errorf("cubeFormatToGrafanaUnit(%q, %q) = %q, want %q", tt.format, tt.currency, got, tt.want)
}
})
}
}

func TestCubeFormatBaseNamePreservesNonNumericSuffix(t *testing.T) {
if got := cubeFormatBaseName("currency_X"); got != "currency_X" {
t.Errorf("cubeFormatBaseName(currency_X) = %q, want %q", got, "currency_X")
}
if got := cubeFormatBaseName("currency_10"); got != "currency_10" {
t.Errorf("cubeFormatBaseName(currency_10) = %q, want %q", got, "currency_10")
}
}

func TestCubeFormatUnmarshalJSON(t *testing.T) {
tests := []struct {
name string
raw string
want string
}{
{"string format", `"currency"`, "currency"},
{"object alias", `{"type":"custom-numeric","value":",.0f","alias":"number_0"}`, "number_0"},
{"object value only", `{"type":"custom-numeric","value":",.2f"}`, ",.2f"},
{"null", `null`, ""},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var f CubeFormat
if err := json.Unmarshal([]byte(tt.raw), &f); err != nil {
t.Fatalf("UnmarshalJSON() error = %v", err)
}
if f.String() != tt.want {
t.Errorf("got %q, want %q", f.String(), tt.want)
}
})
}
}

func TestCubeMetaResponseUnmarshalObjectFormat(t *testing.T) {
raw := `{
"cubes": [{
"name": "orders_view",
"type": "view",
"measures": [{
"name": "orders.count",
"title": "Count",
"type": "number",
"format": {"type":"custom-numeric","value":",.0f","alias":"number_0"}
}]
}]
}`

var meta CubeMetaResponse
if err := json.Unmarshal([]byte(raw), &meta); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if len(meta.Cubes) != 1 || len(meta.Cubes[0].Measures) != 1 {
t.Fatalf("unexpected meta shape: %+v", meta)
}
if got := meta.Cubes[0].Measures[0].Format.String(); got != "number_0" {
t.Errorf("Format = %q, want number_0", got)
}
}
Loading
Loading