Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -7679,25 +7679,32 @@ function formatUiValue(value, options = {}) {
const numeric = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(numeric))
return String(value);
const format = String(options.format || "").toLowerCase();
const percent = options.style === "percent" || format.includes("%") || format.includes("percent") || format.includes("pct") || options.type === "ratio";
const format = String(options.format || "").trim().toLowerCase();
const percent = options.style === "percent" || format.includes("%") || format.includes("percent") || format.includes("pct") || !format && !options.style && !options.currency && options.type === "ratio";
const decimalPattern = format.match(/^[^0#]*[0#,]+(?:\.([0#]+))?(?:\\?%)?$/);
const namedPrecision = format.match(/^(?:decimal|percent|usd)_(\d+)$/);
const precision = decimalPattern ? (decimalPattern[1] || "").length : namedPrecision ? Number(namedPrecision[1]) : undefined;
const maximumFractionDigits = options.maximumFractionDigits ?? (precision === undefined ? percent || options.compact ? 1 : 2 : Math.min(precision, 20));
const minimumFractionDigits = precision === undefined ? undefined : Math.min(decimalPattern ? (decimalPattern[1] || "").replaceAll("#", "").length : precision, maximumFractionDigits);
if (percent) {
const scaled = options.style === "percent" ? numeric : Math.abs(numeric) <= 1 ? numeric * 100 : numeric;
return scaled.toLocaleString(undefined, {
maximumFractionDigits: options.maximumFractionDigits ?? 1,
maximumFractionDigits,
minimumFractionDigits,
style: options.style === "percent" ? "percent" : "decimal"
}) + (options.style === "percent" ? "" : "%");
}
const currency = options.style === "currency" || options.currency || format.includes("$") || format.includes("usd") || format.includes("currency") || format.includes("dollar");
return numeric.toLocaleString(undefined, {
currency: currency ? options.currency || "USD" : undefined,
maximumFractionDigits: options.maximumFractionDigits ?? 2,
maximumFractionDigits,
minimumFractionDigits,
notation: options.compact ? "compact" : "standard",
style: currency ? "currency" : "decimal"
});
}
function formatUiCompact(value, options = {}) {
return formatUiValue(value, { ...options, compact: true, maximumFractionDigits: options.maximumFractionDigits ?? 1 });
return formatUiValue(value, { ...options, compact: true });
}
function normalizeFilterValue(value) {
return String(value ?? "");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7679,25 +7679,32 @@ function formatUiValue(value, options = {}) {
const numeric = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(numeric))
return String(value);
const format = String(options.format || "").toLowerCase();
const percent = options.style === "percent" || format.includes("%") || format.includes("percent") || format.includes("pct") || options.type === "ratio";
const format = String(options.format || "").trim().toLowerCase();
const percent = options.style === "percent" || format.includes("%") || format.includes("percent") || format.includes("pct") || !format && !options.style && !options.currency && options.type === "ratio";
const decimalPattern = format.match(/^[^0#]*[0#,]+(?:\.([0#]+))?(?:\\?%)?$/);
const namedPrecision = format.match(/^(?:decimal|percent|usd)_(\d+)$/);
const precision = decimalPattern ? (decimalPattern[1] || "").length : namedPrecision ? Number(namedPrecision[1]) : undefined;
const maximumFractionDigits = options.maximumFractionDigits ?? (precision === undefined ? percent || options.compact ? 1 : 2 : Math.min(precision, 20));
const minimumFractionDigits = precision === undefined ? undefined : Math.min(decimalPattern ? (decimalPattern[1] || "").replaceAll("#", "").length : precision, maximumFractionDigits);
if (percent) {
const scaled = options.style === "percent" ? numeric : Math.abs(numeric) <= 1 ? numeric * 100 : numeric;
return scaled.toLocaleString(undefined, {
maximumFractionDigits: options.maximumFractionDigits ?? 1,
maximumFractionDigits,
minimumFractionDigits,
style: options.style === "percent" ? "percent" : "decimal"
}) + (options.style === "percent" ? "" : "%");
}
const currency = options.style === "currency" || options.currency || format.includes("$") || format.includes("usd") || format.includes("currency") || format.includes("dollar");
return numeric.toLocaleString(undefined, {
currency: currency ? options.currency || "USD" : undefined,
maximumFractionDigits: options.maximumFractionDigits ?? 2,
maximumFractionDigits,
minimumFractionDigits,
notation: options.compact ? "compact" : "standard",
style: currency ? "currency" : "decimal"
});
}
function formatUiCompact(value, options = {}) {
return formatUiValue(value, { ...options, compact: true, maximumFractionDigits: options.maximumFractionDigits ?? 1 });
return formatUiValue(value, { ...options, compact: true });
}
function normalizeFilterValue(value) {
return String(value ?? "");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,25 +81,32 @@ function formatUiValue(value, options = {}) {
const numeric = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(numeric))
return String(value);
const format = String(options.format || "").toLowerCase();
const percent = options.style === "percent" || format.includes("%") || format.includes("percent") || format.includes("pct") || options.type === "ratio";
const format = String(options.format || "").trim().toLowerCase();
const percent = options.style === "percent" || format.includes("%") || format.includes("percent") || format.includes("pct") || !format && !options.style && !options.currency && options.type === "ratio";
const decimalPattern = format.match(/^[^0#]*[0#,]+(?:\.([0#]+))?(?:\\?%)?$/);
const namedPrecision = format.match(/^(?:decimal|percent|usd)_(\d+)$/);
const precision = decimalPattern ? (decimalPattern[1] || "").length : namedPrecision ? Number(namedPrecision[1]) : undefined;
const maximumFractionDigits = options.maximumFractionDigits ?? (precision === undefined ? percent || options.compact ? 1 : 2 : Math.min(precision, 20));
const minimumFractionDigits = precision === undefined ? undefined : Math.min(decimalPattern ? (decimalPattern[1] || "").replaceAll("#", "").length : precision, maximumFractionDigits);
if (percent) {
const scaled = options.style === "percent" ? numeric : Math.abs(numeric) <= 1 ? numeric * 100 : numeric;
return scaled.toLocaleString(undefined, {
maximumFractionDigits: options.maximumFractionDigits ?? 1,
maximumFractionDigits,
minimumFractionDigits,
style: options.style === "percent" ? "percent" : "decimal"
}) + (options.style === "percent" ? "" : "%");
}
const currency = options.style === "currency" || options.currency || format.includes("$") || format.includes("usd") || format.includes("currency") || format.includes("dollar");
return numeric.toLocaleString(undefined, {
currency: currency ? options.currency || "USD" : undefined,
maximumFractionDigits: options.maximumFractionDigits ?? 2,
maximumFractionDigits,
minimumFractionDigits,
notation: options.compact ? "compact" : "standard",
style: currency ? "currency" : "decimal"
});
}
function formatUiCompact(value, options = {}) {
return formatUiValue(value, { ...options, compact: true, maximumFractionDigits: options.maximumFractionDigits ?? 1 });
return formatUiValue(value, { ...options, compact: true });
}
function normalizeFilterValue(value) {
return String(value ?? "");
Expand Down
22 changes: 22 additions & 0 deletions sidemantic-rs/src/bin/sidemantic-server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1363,6 +1363,9 @@ fn metric_json(metric: &Metric) -> JsonValue {
if let Some(format) = &metric.format {
entry.insert("format".to_string(), json!(format));
}
if let Some(value_format_name) = &metric.value_format_name {
entry.insert("value_format_name".to_string(), json!(value_format_name));
}
if let Some(description) = &metric.description {
entry.insert("description".to_string(), json!(description));
}
Expand Down Expand Up @@ -1553,3 +1556,22 @@ mod security_tests {
}
}
}

#[cfg(test)]
mod metadata_tests {
use super::*;

#[test]
fn metric_metadata_preserves_explicit_and_named_formats() {
let mut metric = Metric::new("aov");
assert!(metric_json(&metric).get("value_format_name").is_none());
metric.value_format_name = Some("usd".into());
let named = metric_json(&metric);
assert_eq!(named["value_format_name"], "usd");
assert!(named.get("format").is_none());
metric.format = Some("#,##0.00".into());
let explicit = metric_json(&metric);
assert_eq!(explicit["format"], "#,##0.00");
assert_eq!(explicit["value_format_name"], "usd");
}
}

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion sidemantic-rs/ui/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sidemantic</title>
<script type="module" crossorigin src="/assets/index-Bh-YPoVm.js"></script>
<script type="module" crossorigin src="/assets/index-C0Mo4KDV.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DnTdRyzm.css">
</head>
<body>
Expand Down
4 changes: 4 additions & 0 deletions sidemantic/core/introspection.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,8 @@ def _describe_dimension(dimension: Any, warnings: list[dict[str, Any]], model: M
info["label"] = dimension.label
if dimension.format:
info["format"] = dimension.format
if dimension.value_format_name:
info["value_format_name"] = dimension.value_format_name
if dimension.meta:
info["meta"] = dimension.meta
return _drop_none(info)
Expand Down Expand Up @@ -142,6 +144,8 @@ def _describe_metric(
info["label"] = metric.label
if metric.format:
info["format"] = metric.format
if metric.value_format_name:
info["value_format_name"] = metric.value_format_name
if metric.meta:
info["meta"] = metric.meta
result = _drop_none(info)
Expand Down

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion sidemantic/ui/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sidemantic</title>
<script type="module" crossorigin src="/assets/index-Bh-YPoVm.js"></script>
<script type="module" crossorigin src="/assets/index-C0Mo4KDV.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DnTdRyzm.css">
</head>
<body>
Expand Down
14 changes: 7 additions & 7 deletions sidemantic/widget/static/widget.js

Large diffs are not rendered by default.

37 changes: 37 additions & 0 deletions tests/core/test_introspection.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
from sidemantic import Dimension, Metric, Model, SemanticLayer


def test_describe_preserves_explicit_and_named_formats():
layer = SemanticLayer()
layer.add_model(
Model(
name="orders",
table="orders",
dimensions=[Dimension(name="amount", type="numeric", value_format_name="usd")],
metrics=[
Metric(name="revenue", agg="sum", sql="amount", value_format_name="usd"),
Metric(name="count", agg="count"),
Metric(
name="aov",
type="ratio",
numerator="revenue",
denominator="count",
format="#,##0.00",
value_format_name="decimal_2",
),
],
)
)
layer.add_metric(Metric(name="global_revenue", type="derived", sql="orders.revenue", value_format_name="usd"))

described = layer.describe_models()
orders = described["models"][0]
assert orders["dimensions"][0]["value_format_name"] == "usd"
revenue, count, aov = orders["metrics"]
assert revenue["value_format_name"] == "usd"
assert "format" not in revenue
assert "value_format_name" not in count
assert aov["format"] == "#,##0.00"
assert aov["value_format_name"] == "decimal_2"
global_revenue = next(metric for metric in described["metrics"] if metric["name"] == "global_revenue")
assert global_revenue["value_format_name"] == "usd"
29 changes: 29 additions & 0 deletions webapp/src/lib/catalog.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { expect, test } from "bun:test";
import { buildCatalogFromDescribe } from "./catalog";
import { formatCompact, formatValue } from "./format";

test("describe formats reach model and graph metric displays, with explicit formats taking precedence", () => {
const catalog = buildCatalogFromDescribe({
models: [{
name: "orders",
dimensions: [{ name: "amount", value_format_name: "usd" }],
metrics: [
{ name: "arppu", type: "ratio", value_format_name: "decimal_2" },
{ name: "arpu", type: "ratio", value_format_name: "percent", format: "#,##0.00" },
{ name: "aov", type: "ratio", value_format_name: "usd" },
],
}],
metrics: [
{ name: "aov", type: "ratio", value_format_name: "usd" },
{ name: "conversion", type: "ratio", value_format_name: "percent_2" },
{ name: "arpu", type: "ratio", value_format_name: "percent", format: "#,##0.00" },
],
});
expect(formatValue(0.59, catalog.models[0].dimensions[0])).toBe("$0.59");
expect(formatValue(160907.2, catalog.models[0].metrics[0])).toBe("160,907.20");
expect(formatValue(0.59, catalog.models[0].metrics[1])).toBe("0.59");
expect(formatValue(0.59, catalog.models[0].metrics[2])).toBe("$0.59");
expect(formatCompact(160907.2, catalog.graphMetrics[0])).toBe("$160.9K");
expect(formatValue(0.59, catalog.graphMetrics[1])).toBe("59.00%");
expect(formatValue(0.59, catalog.graphMetrics[2])).toBe("0.59");
});
8 changes: 5 additions & 3 deletions webapp/src/lib/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ type DescribeDimension = {
supported_granularities?: string[];
label?: string;
format?: string;
value_format_name?: string;
description?: string;
public?: boolean;
};
Expand All @@ -20,6 +21,7 @@ type DescribeMetric = {
type?: string;
label?: string;
format?: string;
value_format_name?: string;
description?: string;
public?: boolean;
sql?: string;
Expand Down Expand Up @@ -116,7 +118,7 @@ export function buildCatalogFromDescribe(payload: DescribeResponse): Catalog {
type: dim.type || "categorical",
granularity: dim.granularity,
supportedGranularities: dim.supported_granularities,
format: dim.format,
format: dim.format ?? dim.value_format_name,
}));
const metrics: CatalogMetric[] = (model.metrics ?? []).filter(isPublic).map((metric) => ({
ref: `${model.name}.${metric.name}`,
Expand All @@ -126,7 +128,7 @@ export function buildCatalogFromDescribe(payload: DescribeResponse): Catalog {
description: metric.description,
agg: metric.agg,
type: metric.type,
format: metric.format,
format: metric.format ?? metric.value_format_name,
}));
const timeDimension = pickTimeDimension(dimensions, model.default_time_dimension);
return {
Expand All @@ -149,7 +151,7 @@ export function buildCatalogFromDescribe(payload: DescribeResponse): Catalog {
label: metric.label || labelize(metric.name),
description: metric.description,
type: metric.type,
format: metric.format,
format: metric.format ?? metric.value_format_name,
}));

return { models, graphMetrics, dialect: payload.dialect };
Expand Down
20 changes: 15 additions & 5 deletions webapp/src/lib/uiCore.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,26 +14,36 @@ export function formatUiValue(value, options = {}) {
if (value === null || value === undefined || value === "") return "—";
const numeric = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(numeric)) return String(value);
const format = String(options.format || "").toLowerCase();
const percent = options.style === "percent" || format.includes("%") || format.includes("percent") || format.includes("pct") || options.type === "ratio";
const format = String(options.format || "").trim().toLowerCase();
// Explicit display hints override the legacy percentage default for ratios.
const percent = options.style === "percent" || format.includes("%") || format.includes("percent") || format.includes("pct")
|| (!format && !options.style && !options.currency && options.type === "ratio");
const decimalPattern = format.match(/^[^0#]*[0#,]+(?:\.([0#]+))?(?:\\?%)?$/);
const namedPrecision = format.match(/^(?:decimal|percent|usd)_(\d+)$/);
const precision = decimalPattern ? (decimalPattern[1] || "").length : namedPrecision ? Number(namedPrecision[1]) : undefined;
const maximumFractionDigits = options.maximumFractionDigits ?? (precision === undefined ? (percent || options.compact ? 1 : 2) : Math.min(precision, 20));
const minimumFractionDigits = precision === undefined ? undefined
: Math.min(decimalPattern ? (decimalPattern[1] || "").replaceAll("#", "").length : precision, maximumFractionDigits);
if (percent) {
const scaled = options.style === "percent" ? numeric : Math.abs(numeric) <= 1 ? numeric * 100 : numeric;
return scaled.toLocaleString(undefined, {
maximumFractionDigits: options.maximumFractionDigits ?? 1,
maximumFractionDigits,
minimumFractionDigits,
style: options.style === "percent" ? "percent" : "decimal",
}) + (options.style === "percent" ? "" : "%");
}
const currency = options.style === "currency" || options.currency || format.includes("$") || format.includes("usd") || format.includes("currency") || format.includes("dollar");
return numeric.toLocaleString(undefined, {
currency: currency ? options.currency || "USD" : undefined,
maximumFractionDigits: options.maximumFractionDigits ?? 2,
maximumFractionDigits,
minimumFractionDigits,
notation: options.compact ? "compact" : "standard",
style: currency ? "currency" : "decimal",
});
}

export function formatUiCompact(value, options = {}) {
return formatUiValue(value, { ...options, compact: true, maximumFractionDigits: options.maximumFractionDigits ?? 1 });
return formatUiValue(value, { ...options, compact: true });
}

export function normalizeFilterValue(value) {
Expand Down
Loading
Loading