something
This commit is contained in:
@@ -94,3 +94,404 @@ message AnalyticsResultBatch {
|
||||
uint64 row_count = 5;
|
||||
uint64 elapsed_ms = 6;
|
||||
}
|
||||
|
||||
service ReportingService {
|
||||
rpc ListAssets(ListReportAssetsRequest) returns (ListReportAssetsResponse);
|
||||
rpc GetAsset(GetReportAssetRequest) returns (ReportAsset);
|
||||
rpc SaveDraft(SaveReportDraftRequest) returns (ReportAsset);
|
||||
rpc Publish(PublishReportRequest) returns (ReportAsset);
|
||||
rpc ListVersions(ReportAssetRef) returns (ListReportVersionsResponse);
|
||||
rpc RestoreDraft(RestoreReportDraftRequest) returns (ReportAsset);
|
||||
rpc SetArchived(SetReportArchivedRequest) returns (ReportAsset);
|
||||
rpc ExecuteDataset(ExecuteReportDatasetRequest) returns (stream AnalyticsResultBatch);
|
||||
rpc GetPersonalViews(ReportAssetRef) returns (GetReportPersonalViewsResponse);
|
||||
rpc SavePersonalView(SaveReportPersonalViewRequest) returns (ReportPersonalView);
|
||||
rpc DeletePersonalView(DeleteReportPersonalViewRequest) returns (ReportMutationResult);
|
||||
}
|
||||
|
||||
enum ReportAssetKind {
|
||||
REPORT_ASSET_KIND_UNSPECIFIED = 0;
|
||||
REPORT_ASSET_KIND_DATASET = 1;
|
||||
REPORT_ASSET_KIND_DASHBOARD = 2;
|
||||
}
|
||||
|
||||
enum ReportReadMode {
|
||||
REPORT_READ_MODE_UNSPECIFIED = 0;
|
||||
REPORT_READ_MODE_PUBLISHED = 1;
|
||||
REPORT_READ_MODE_DRAFT = 2;
|
||||
REPORT_READ_MODE_VERSION = 3;
|
||||
}
|
||||
|
||||
enum ReportDataType {
|
||||
REPORT_DATA_TYPE_UNSPECIFIED = 0;
|
||||
REPORT_DATA_TYPE_TEXT = 1;
|
||||
REPORT_DATA_TYPE_INTEGER = 2;
|
||||
REPORT_DATA_TYPE_DECIMAL = 3;
|
||||
REPORT_DATA_TYPE_DATE = 4;
|
||||
REPORT_DATA_TYPE_TIMESTAMP = 5;
|
||||
REPORT_DATA_TYPE_BOOLEAN = 6;
|
||||
}
|
||||
|
||||
enum ReportFilterControl {
|
||||
REPORT_FILTER_CONTROL_UNSPECIFIED = 0;
|
||||
REPORT_FILTER_CONTROL_TEXT = 1;
|
||||
REPORT_FILTER_CONTROL_NUMBER = 2;
|
||||
REPORT_FILTER_CONTROL_DATE = 3;
|
||||
REPORT_FILTER_CONTROL_TIMESTAMP = 4;
|
||||
REPORT_FILTER_CONTROL_SELECT = 5;
|
||||
REPORT_FILTER_CONTROL_MULTISELECT = 6;
|
||||
REPORT_FILTER_CONTROL_CHECKBOX = 7;
|
||||
}
|
||||
|
||||
enum ReportDefaultKind {
|
||||
REPORT_DEFAULT_KIND_UNSPECIFIED = 0;
|
||||
REPORT_DEFAULT_KIND_LITERAL = 1;
|
||||
REPORT_DEFAULT_KIND_TODAY = 2;
|
||||
REPORT_DEFAULT_KIND_MONTH_START = 3;
|
||||
REPORT_DEFAULT_KIND_YEAR_START = 4;
|
||||
}
|
||||
|
||||
enum ReportNumberFormat {
|
||||
REPORT_NUMBER_FORMAT_UNSPECIFIED = 0;
|
||||
REPORT_NUMBER_FORMAT_NUMBER = 1;
|
||||
REPORT_NUMBER_FORMAT_CURRENCY = 2;
|
||||
REPORT_NUMBER_FORMAT_PERCENT = 3;
|
||||
REPORT_NUMBER_FORMAT_COMPACT = 4;
|
||||
}
|
||||
|
||||
enum ReportPanelKind {
|
||||
REPORT_PANEL_KIND_UNSPECIFIED = 0;
|
||||
REPORT_PANEL_KIND_BAR = 1;
|
||||
REPORT_PANEL_KIND_LINE = 2;
|
||||
REPORT_PANEL_KIND_AREA = 3;
|
||||
REPORT_PANEL_KIND_PIE = 4;
|
||||
REPORT_PANEL_KIND_DONUT = 5;
|
||||
REPORT_PANEL_KIND_SCATTER = 6;
|
||||
REPORT_PANEL_KIND_HEATMAP = 7;
|
||||
REPORT_PANEL_KIND_TREEMAP = 8;
|
||||
REPORT_PANEL_KIND_FUNNEL = 9;
|
||||
REPORT_PANEL_KIND_GAUGE = 10;
|
||||
REPORT_PANEL_KIND_WATERFALL = 11;
|
||||
REPORT_PANEL_KIND_TABLE = 12;
|
||||
REPORT_PANEL_KIND_KPI = 13;
|
||||
}
|
||||
|
||||
enum ReportOrientation {
|
||||
REPORT_ORIENTATION_UNSPECIFIED = 0;
|
||||
REPORT_ORIENTATION_VERTICAL = 1;
|
||||
REPORT_ORIENTATION_HORIZONTAL = 2;
|
||||
}
|
||||
|
||||
enum ReportSortOrder {
|
||||
REPORT_SORT_ORDER_UNSPECIFIED = 0;
|
||||
REPORT_SORT_ORDER_SOURCE = 1;
|
||||
REPORT_SORT_ORDER_ASCENDING = 2;
|
||||
REPORT_SORT_ORDER_DESCENDING = 3;
|
||||
}
|
||||
|
||||
enum ReportNullPolicy {
|
||||
REPORT_NULL_POLICY_UNSPECIFIED = 0;
|
||||
REPORT_NULL_POLICY_GAP = 1;
|
||||
REPORT_NULL_POLICY_ZERO = 2;
|
||||
}
|
||||
|
||||
enum ReportCapability {
|
||||
REPORT_CAPABILITY_UNSPECIFIED = 0;
|
||||
REPORT_CAPABILITY_VIEW = 1;
|
||||
REPORT_CAPABILITY_FILTER = 2;
|
||||
REPORT_CAPABILITY_DRILL = 3;
|
||||
REPORT_CAPABILITY_EXPORT = 4;
|
||||
REPORT_CAPABILITY_CUSTOMIZE = 5;
|
||||
}
|
||||
|
||||
enum ReportExecutionPurpose {
|
||||
REPORT_EXECUTION_PURPOSE_UNSPECIFIED = 0;
|
||||
REPORT_EXECUTION_PURPOSE_VIEW = 1;
|
||||
REPORT_EXECUTION_PURPOSE_PREVIEW = 2;
|
||||
REPORT_EXECUTION_PURPOSE_EXPORT = 3;
|
||||
REPORT_EXECUTION_PURPOSE_DRILL = 4;
|
||||
}
|
||||
|
||||
message ReportScalar {
|
||||
oneof value {
|
||||
google.protobuf.NullValue null_value = 1;
|
||||
string text = 2;
|
||||
string integer = 3;
|
||||
string decimal = 4;
|
||||
string date = 5;
|
||||
string timestamp = 6;
|
||||
bool boolean = 7;
|
||||
}
|
||||
}
|
||||
|
||||
message ReportParameterChoice {
|
||||
string label = 1;
|
||||
ReportScalar value = 2;
|
||||
}
|
||||
|
||||
message ReportParameterLookup {
|
||||
string dataset_id = 1;
|
||||
string value_field = 2;
|
||||
string label_field = 3;
|
||||
}
|
||||
|
||||
message ReportParameter {
|
||||
string key = 1;
|
||||
string label = 2;
|
||||
ReportDataType data_type = 3;
|
||||
ReportFilterControl control = 4;
|
||||
bool required = 5;
|
||||
bool multiple = 6;
|
||||
repeated ReportScalar default_values = 7;
|
||||
ReportDefaultKind default_kind = 8;
|
||||
repeated ReportParameterChoice choices = 9;
|
||||
ReportParameterLookup lookup = 10;
|
||||
}
|
||||
|
||||
message ReportParameterBinding {
|
||||
string key = 1;
|
||||
repeated ReportScalar values = 2;
|
||||
}
|
||||
|
||||
message ReportDatasetColumn {
|
||||
string key = 1;
|
||||
string label = 2;
|
||||
ReportDataType data_type = 3;
|
||||
ReportNumberFormat number_format = 4;
|
||||
string currency = 5;
|
||||
string currency_field = 6;
|
||||
optional uint32 fraction_digits = 7;
|
||||
}
|
||||
|
||||
message ReportDatasetDefinition {
|
||||
string title = 1;
|
||||
string description = 2;
|
||||
// Parameters use named DataFusion placeholders, for example CAST($from AS DATE).
|
||||
string sql = 3;
|
||||
repeated ReportParameter parameters = 4;
|
||||
repeated ReportDatasetColumn columns = 5;
|
||||
uint32 max_rows = 6;
|
||||
}
|
||||
|
||||
message ReportFilterTarget {
|
||||
string dataset_id = 1;
|
||||
string parameter_key = 2;
|
||||
}
|
||||
|
||||
message ReportDashboardFilter {
|
||||
ReportParameter parameter = 1;
|
||||
repeated ReportFilterTarget targets = 2;
|
||||
}
|
||||
|
||||
message ReportActionBinding {
|
||||
string filter_key = 1;
|
||||
string column_key = 2;
|
||||
}
|
||||
|
||||
message ReportFilterAction {
|
||||
repeated ReportActionBinding bindings = 1;
|
||||
}
|
||||
|
||||
message ReportRecordAction {
|
||||
string table_name = 1;
|
||||
string id_field = 2;
|
||||
}
|
||||
|
||||
message ReportDashboardAction {
|
||||
string dashboard_id = 1;
|
||||
repeated ReportActionBinding bindings = 2;
|
||||
}
|
||||
|
||||
message ReportPanelAction {
|
||||
string label = 1;
|
||||
oneof target {
|
||||
ReportFilterAction filter = 2;
|
||||
ReportRecordAction record = 3;
|
||||
ReportDashboardAction dashboard = 4;
|
||||
}
|
||||
}
|
||||
|
||||
message ReportPanel {
|
||||
string id = 1;
|
||||
string title = 2;
|
||||
string description = 3;
|
||||
string dataset_id = 4;
|
||||
ReportPanelKind kind = 5;
|
||||
string x_field = 6;
|
||||
repeated string y_fields = 7;
|
||||
string series_field = 8;
|
||||
string size_field = 9;
|
||||
repeated string table_fields = 10;
|
||||
ReportOrientation orientation = 11;
|
||||
bool stacked = 12;
|
||||
bool show_legend = 13;
|
||||
bool show_labels = 14;
|
||||
uint32 width = 15;
|
||||
uint32 height = 16;
|
||||
repeated string colors = 17;
|
||||
ReportSortOrder sort_order = 18;
|
||||
string sort_field = 19;
|
||||
ReportNullPolicy null_policy = 20;
|
||||
optional double axis_min = 21;
|
||||
optional double axis_max = 22;
|
||||
repeated ReportPanelAction actions = 23;
|
||||
}
|
||||
|
||||
message ReportGrant {
|
||||
oneof subject {
|
||||
string role = 1;
|
||||
string user_id = 2;
|
||||
}
|
||||
repeated ReportCapability capabilities = 3;
|
||||
}
|
||||
|
||||
message ReportDashboardDefinition {
|
||||
string title = 1;
|
||||
string description = 2;
|
||||
repeated ReportDashboardFilter filters = 3;
|
||||
repeated ReportPanel panels = 4;
|
||||
repeated ReportGrant grants = 5;
|
||||
uint32 refresh_seconds = 6;
|
||||
repeated ReportDatasetVersionRef dataset_versions = 7;
|
||||
}
|
||||
|
||||
message ReportDatasetVersionRef {
|
||||
string dataset_id = 1;
|
||||
// Zero selects the latest published dataset when the dashboard is published.
|
||||
uint64 version = 2;
|
||||
}
|
||||
|
||||
message ReportDefinition {
|
||||
uint32 schema_version = 1;
|
||||
oneof content {
|
||||
ReportDatasetDefinition dataset = 2;
|
||||
ReportDashboardDefinition dashboard = 3;
|
||||
}
|
||||
}
|
||||
|
||||
message ReportDatasetSnapshot {
|
||||
string dataset_id = 1;
|
||||
uint64 version = 2;
|
||||
ReportDatasetDefinition definition = 3;
|
||||
}
|
||||
|
||||
message ReportAssetRef {
|
||||
string profile_name = 1;
|
||||
string asset_id = 2;
|
||||
}
|
||||
|
||||
message ReportAssetSummary {
|
||||
string id = 1;
|
||||
string title = 2;
|
||||
string description = 3;
|
||||
ReportAssetKind kind = 4;
|
||||
uint64 draft_revision = 5;
|
||||
uint64 published_version = 6;
|
||||
bool archived = 7;
|
||||
string updated_at = 8;
|
||||
repeated ReportCapability capabilities = 9;
|
||||
}
|
||||
|
||||
message ReportAsset {
|
||||
ReportAssetSummary summary = 1;
|
||||
ReportDefinition definition = 2;
|
||||
repeated ReportDatasetSnapshot datasets = 3;
|
||||
string profile_name = 4;
|
||||
uint64 version = 5;
|
||||
}
|
||||
|
||||
message ListReportAssetsRequest {
|
||||
string profile_name = 1;
|
||||
bool include_archived = 2;
|
||||
}
|
||||
|
||||
message ListReportAssetsResponse {
|
||||
repeated ReportAssetSummary assets = 1;
|
||||
bool can_manage = 2;
|
||||
}
|
||||
|
||||
message GetReportAssetRequest {
|
||||
ReportAssetRef asset = 1;
|
||||
ReportReadMode mode = 2;
|
||||
uint64 version = 3;
|
||||
}
|
||||
|
||||
message SaveReportDraftRequest {
|
||||
ReportAssetRef asset = 1;
|
||||
uint64 expected_revision = 2;
|
||||
ReportDefinition definition = 3;
|
||||
}
|
||||
|
||||
message PublishReportRequest {
|
||||
ReportAssetRef asset = 1;
|
||||
uint64 expected_revision = 2;
|
||||
}
|
||||
|
||||
message ReportVersionSummary {
|
||||
uint64 version = 1;
|
||||
string title = 2;
|
||||
string created_at = 3;
|
||||
string created_by = 4;
|
||||
}
|
||||
|
||||
message ListReportVersionsResponse {
|
||||
repeated ReportVersionSummary versions = 1;
|
||||
}
|
||||
|
||||
message RestoreReportDraftRequest {
|
||||
ReportAssetRef asset = 1;
|
||||
uint64 expected_revision = 2;
|
||||
uint64 version = 3;
|
||||
}
|
||||
|
||||
message SetReportArchivedRequest {
|
||||
ReportAssetRef asset = 1;
|
||||
uint64 expected_revision = 2;
|
||||
bool archived = 3;
|
||||
}
|
||||
|
||||
message ExecuteReportDatasetRequest {
|
||||
ReportAssetRef asset = 1;
|
||||
// Published version for viewer requests; draft revision for previews.
|
||||
uint64 version = 2;
|
||||
string dataset_id = 3;
|
||||
repeated ReportParameterBinding filters = 4;
|
||||
ReportExecutionPurpose purpose = 5;
|
||||
string panel_id = 6;
|
||||
uint32 action_index = 7;
|
||||
}
|
||||
|
||||
message ReportPanelPreference {
|
||||
string panel_id = 1;
|
||||
uint32 width = 2;
|
||||
uint32 height = 3;
|
||||
bool hidden = 4;
|
||||
}
|
||||
|
||||
message ReportPersonalView {
|
||||
string id = 1;
|
||||
string title = 2;
|
||||
uint64 report_version = 3;
|
||||
repeated ReportParameterBinding filters = 4;
|
||||
repeated ReportPanelPreference panels = 5;
|
||||
uint64 revision = 6;
|
||||
}
|
||||
|
||||
message GetReportPersonalViewsResponse {
|
||||
repeated ReportPersonalView views = 1;
|
||||
}
|
||||
|
||||
message SaveReportPersonalViewRequest {
|
||||
ReportAssetRef asset = 1;
|
||||
ReportPersonalView view = 2;
|
||||
}
|
||||
|
||||
message DeleteReportPersonalViewRequest {
|
||||
ReportAssetRef asset = 1;
|
||||
string view_id = 2;
|
||||
uint64 expected_revision = 3;
|
||||
}
|
||||
|
||||
message ReportMutationResult {
|
||||
bool success = 1;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ pub mod decimal;
|
||||
pub mod grpc_error;
|
||||
pub mod money;
|
||||
pub mod relationship;
|
||||
pub mod reporting;
|
||||
pub mod script_operations;
|
||||
pub mod system_column;
|
||||
pub mod typst_contract;
|
||||
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
550
common/src/reporting.rs
Normal file
550
common/src/reporting.rs
Normal file
@@ -0,0 +1,550 @@
|
||||
use crate::proto::komp_ac::analytics::*;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
pub const REPORT_SCHEMA_VERSION: u32 = 1;
|
||||
pub const MAX_REPORT_BYTES: usize = 1024 * 1024;
|
||||
pub const MAX_DATASET_ROWS: u32 = 10_000;
|
||||
|
||||
pub fn enum_value<T: TryFrom<i32>>(value: i32, field: &str) -> Result<T, String> {
|
||||
if value == 0 {
|
||||
return Err(format!("{field} is required"));
|
||||
}
|
||||
T::try_from(value).map_err(|_| format!("Invalid {field}: {value}"))
|
||||
}
|
||||
|
||||
pub fn asset_kind(definition: &ReportDefinition) -> Result<ReportAssetKind, String> {
|
||||
match &definition.content {
|
||||
Some(report_definition::Content::Dataset(_)) => Ok(ReportAssetKind::Dataset),
|
||||
Some(report_definition::Content::Dashboard(_)) => Ok(ReportAssetKind::Dashboard),
|
||||
None => Err("A report definition is required".into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn title(definition: &ReportDefinition) -> &str {
|
||||
match &definition.content {
|
||||
Some(report_definition::Content::Dataset(dataset)) => &dataset.title,
|
||||
Some(report_definition::Content::Dashboard(dashboard)) => &dashboard.title,
|
||||
None => "",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn description(definition: &ReportDefinition) -> &str {
|
||||
match &definition.content {
|
||||
Some(report_definition::Content::Dataset(dataset)) => &dataset.description,
|
||||
Some(report_definition::Content::Dashboard(dashboard)) => &dashboard.description,
|
||||
None => "",
|
||||
}
|
||||
}
|
||||
|
||||
fn nonempty(value: &str, label: &str, limit: usize) -> Result<(), String> {
|
||||
if value.trim().is_empty() || value.len() > limit || value.contains('\0') {
|
||||
return Err(format!("{label} must contain between 1 and {limit} bytes"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_parameter(parameter: &ReportParameter, allow_lookup: bool) -> Result<(), String> {
|
||||
nonempty(¶meter.key, "Parameter key", 64)?;
|
||||
if !parameter.key.bytes().enumerate().all(|(index, byte)| {
|
||||
byte.is_ascii_alphabetic() || byte == b'_' || (index > 0 && byte.is_ascii_digit())
|
||||
}) {
|
||||
return Err(format!(
|
||||
"Parameter '{}' must be a SQL identifier",
|
||||
parameter.key
|
||||
));
|
||||
}
|
||||
nonempty(¶meter.label, "Parameter label", 160)?;
|
||||
let kind: ReportDataType = enum_value(parameter.data_type, "parameter data type")?;
|
||||
let control: ReportFilterControl = enum_value(parameter.control, "filter control")?;
|
||||
let default: ReportDefaultKind = enum_value(parameter.default_kind, "parameter default")?;
|
||||
let valid_control = match control {
|
||||
ReportFilterControl::Text => kind == ReportDataType::Text,
|
||||
ReportFilterControl::Number => {
|
||||
matches!(kind, ReportDataType::Integer | ReportDataType::Decimal)
|
||||
}
|
||||
ReportFilterControl::Date => kind == ReportDataType::Date,
|
||||
ReportFilterControl::Timestamp => kind == ReportDataType::Timestamp,
|
||||
ReportFilterControl::Checkbox => kind == ReportDataType::Boolean,
|
||||
ReportFilterControl::Select | ReportFilterControl::Multiselect => true,
|
||||
ReportFilterControl::Unspecified => false,
|
||||
};
|
||||
if !valid_control || parameter.multiple != (control == ReportFilterControl::Multiselect) {
|
||||
return Err(format!(
|
||||
"Incompatible control for parameter '{}'",
|
||||
parameter.key
|
||||
));
|
||||
}
|
||||
if parameter.default_values.len() > 500
|
||||
|| (!parameter.multiple && parameter.default_values.len() > 1)
|
||||
{
|
||||
return Err(format!(
|
||||
"Too many default values for parameter '{}'",
|
||||
parameter.key
|
||||
));
|
||||
}
|
||||
if default != ReportDefaultKind::Literal
|
||||
&& (kind != ReportDataType::Date
|
||||
|| parameter.multiple
|
||||
|| !parameter.default_values.is_empty())
|
||||
{
|
||||
return Err(
|
||||
"Relative defaults require a single date parameter without literal defaults".into(),
|
||||
);
|
||||
}
|
||||
if parameter.choices.len() > 500 {
|
||||
return Err("A filter can have at most 500 static choices".into());
|
||||
}
|
||||
let mut choices = HashSet::new();
|
||||
for choice in ¶meter.choices {
|
||||
nonempty(&choice.label, "Choice label", 160)?;
|
||||
let value = choice.value.as_ref().ok_or("A choice value is required")?;
|
||||
if !choices.insert(value) {
|
||||
return Err(format!(
|
||||
"Duplicate choices for parameter '{}'",
|
||||
parameter.key
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(lookup) = ¶meter.lookup {
|
||||
if !allow_lookup
|
||||
|| !parameter.choices.is_empty()
|
||||
|| !matches!(
|
||||
control,
|
||||
ReportFilterControl::Select | ReportFilterControl::Multiselect
|
||||
)
|
||||
{
|
||||
return Err(
|
||||
"Lookup datasets belong to dashboard selection filters without static choices"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
nonempty(&lookup.dataset_id, "Lookup dataset", 64)?;
|
||||
nonempty(&lookup.value_field, "Lookup value field", 256)?;
|
||||
nonempty(&lookup.label_field, "Lookup label field", 256)?;
|
||||
}
|
||||
if (!parameter.choices.is_empty() || parameter.lookup.is_some())
|
||||
&& !matches!(
|
||||
control,
|
||||
ReportFilterControl::Select | ReportFilterControl::Multiselect
|
||||
)
|
||||
{
|
||||
return Err("Only selection filters can have choices".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_definition(definition: &ReportDefinition) -> Result<(), String> {
|
||||
if definition.schema_version != REPORT_SCHEMA_VERSION {
|
||||
return Err(format!(
|
||||
"Unsupported report schema version {}",
|
||||
definition.schema_version
|
||||
));
|
||||
}
|
||||
if serde_json::to_vec(definition)
|
||||
.map_err(|error| error.to_string())?
|
||||
.len()
|
||||
> MAX_REPORT_BYTES
|
||||
{
|
||||
return Err("Report definition exceeds 1 MiB".into());
|
||||
}
|
||||
nonempty(title(definition), "Title", 160)?;
|
||||
if description(definition).len() > 4000 {
|
||||
return Err("Description exceeds 4000 bytes".into());
|
||||
}
|
||||
match definition
|
||||
.content
|
||||
.as_ref()
|
||||
.ok_or("A report definition is required")?
|
||||
{
|
||||
report_definition::Content::Dataset(dataset) => validate_dataset(dataset),
|
||||
report_definition::Content::Dashboard(dashboard) => validate_dashboard(dashboard),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_dataset(dataset: &ReportDatasetDefinition) -> Result<(), String> {
|
||||
nonempty(&dataset.sql, "SQL", 64 * 1024)?;
|
||||
if dataset.max_rows == 0 || dataset.max_rows > MAX_DATASET_ROWS {
|
||||
return Err(format!(
|
||||
"Dataset row limit must be between 1 and {MAX_DATASET_ROWS}"
|
||||
));
|
||||
}
|
||||
if dataset.parameters.len() > 32 || dataset.columns.is_empty() || dataset.columns.len() > 128 {
|
||||
return Err("Datasets allow up to 32 parameters and between 1 and 128 columns".into());
|
||||
}
|
||||
let mut keys = HashSet::new();
|
||||
for parameter in &dataset.parameters {
|
||||
validate_parameter(parameter, false)?;
|
||||
if !keys.insert(¶meter.key) {
|
||||
return Err(format!("Duplicate parameter '{}'", parameter.key));
|
||||
}
|
||||
}
|
||||
keys.clear();
|
||||
for column in &dataset.columns {
|
||||
nonempty(&column.key, "Column key", 256)?;
|
||||
nonempty(&column.label, "Column label", 160)?;
|
||||
if !keys.insert(&column.key) {
|
||||
return Err(format!("Duplicate dataset column '{}'", column.key));
|
||||
}
|
||||
let kind: ReportDataType = enum_value(column.data_type, "column data type")?;
|
||||
let format: ReportNumberFormat = enum_value(column.number_format, "number format")?;
|
||||
if column.fraction_digits.is_some_and(|digits| digits > 28) {
|
||||
return Err("Fraction digits cannot exceed 28".into());
|
||||
}
|
||||
if format != ReportNumberFormat::Number
|
||||
&& !matches!(kind, ReportDataType::Integer | ReportDataType::Decimal)
|
||||
{
|
||||
return Err("Number formatting requires a numeric column".into());
|
||||
}
|
||||
if format == ReportNumberFormat::Currency {
|
||||
if column.currency.is_empty() == column.currency_field.is_empty() {
|
||||
return Err(
|
||||
"Currency formatting needs either a currency code or a currency field".into(),
|
||||
);
|
||||
}
|
||||
if !column.currency.is_empty()
|
||||
&& (column.currency.len() != 3
|
||||
|| !column
|
||||
.currency
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_uppercase()))
|
||||
{
|
||||
return Err("Currency codes must have three uppercase letters".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
for column in &dataset.columns {
|
||||
if !column.currency_field.is_empty()
|
||||
&& !dataset.columns.iter().any(|other| {
|
||||
other.key == column.currency_field && other.data_type == ReportDataType::Text as i32
|
||||
})
|
||||
{
|
||||
return Err(format!("Invalid currency field for '{}'", column.key));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_dashboard(dashboard: &ReportDashboardDefinition) -> Result<(), String> {
|
||||
let referenced = referenced_datasets(dashboard);
|
||||
let mut dataset_versions = HashSet::new();
|
||||
for reference in &dashboard.dataset_versions {
|
||||
if !referenced.contains(reference.dataset_id.as_str()) || !dataset_versions.insert(&reference.dataset_id) {
|
||||
return Err("Dataset version references must be unique and used by the dashboard".into());
|
||||
}
|
||||
}
|
||||
if dashboard.filters.len() > 32
|
||||
|| dashboard.panels.is_empty()
|
||||
|| dashboard.panels.len() > 32
|
||||
|| dashboard.grants.len() > 200
|
||||
{
|
||||
return Err(
|
||||
"Dashboards allow up to 32 filters, between 1 and 32 panels, and up to 200 grants"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
if dashboard.refresh_seconds != 0 && !(30..=86400).contains(&dashboard.refresh_seconds) {
|
||||
return Err("Refresh interval must be disabled or between 30 and 86400 seconds".into());
|
||||
}
|
||||
let mut filters = HashSet::new();
|
||||
let mut targets = HashSet::new();
|
||||
for filter in &dashboard.filters {
|
||||
let parameter = filter
|
||||
.parameter
|
||||
.as_ref()
|
||||
.ok_or("A dashboard filter needs a parameter")?;
|
||||
validate_parameter(parameter, true)?;
|
||||
if !filters.insert(¶meter.key) || filter.targets.is_empty() || filter.targets.len() > 32
|
||||
{
|
||||
return Err("Dashboard filters need unique keys and between 1 and 32 targets".into());
|
||||
}
|
||||
for target in &filter.targets {
|
||||
if !targets.insert((&target.dataset_id, &target.parameter_key)) {
|
||||
return Err("A dataset parameter cannot be controlled by multiple filters".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut panels = HashSet::new();
|
||||
for panel in &dashboard.panels {
|
||||
nonempty(&panel.id, "Panel ID", 64)?;
|
||||
nonempty(&panel.title, "Panel title", 160)?;
|
||||
nonempty(&panel.dataset_id, "Panel dataset", 64)?;
|
||||
if !panels.insert(&panel.id) {
|
||||
return Err("Panel IDs must be unique".into());
|
||||
}
|
||||
let _: ReportPanelKind = enum_value(panel.kind, "panel kind")?;
|
||||
let _: ReportOrientation = enum_value(panel.orientation, "orientation")?;
|
||||
let sort: ReportSortOrder = enum_value(panel.sort_order, "sort order")?;
|
||||
let _: ReportNullPolicy = enum_value(panel.null_policy, "null policy")?;
|
||||
if !(1..=12).contains(&panel.width) || !(160..=1200).contains(&panel.height) {
|
||||
return Err("Panel width must be 1–12 and height 160–1200".into());
|
||||
}
|
||||
if sort != ReportSortOrder::Source && panel.sort_field.is_empty() {
|
||||
return Err("A sorted panel needs a sort field".into());
|
||||
}
|
||||
if panel.axis_min.is_some_and(|number| !number.is_finite())
|
||||
|| panel.axis_max.is_some_and(|number| !number.is_finite())
|
||||
|| matches!((panel.axis_min, panel.axis_max), (Some(min), Some(max)) if min >= max)
|
||||
{
|
||||
return Err("Invalid chart axis bounds".into());
|
||||
}
|
||||
if panel.colors.len() > 32
|
||||
|| panel.colors.iter().any(|color| {
|
||||
!matches!(color.len(), 4 | 7)
|
||||
|| !color.starts_with('#')
|
||||
|| !color[1..].bytes().all(|byte| byte.is_ascii_hexdigit())
|
||||
})
|
||||
{
|
||||
return Err("Chart colors must be hexadecimal CSS colors".into());
|
||||
}
|
||||
if panel.actions.len() > 8 {
|
||||
return Err("A panel can have at most eight actions".into());
|
||||
}
|
||||
for action in &panel.actions {
|
||||
nonempty(&action.label, "Action label", 160)?;
|
||||
match action
|
||||
.target
|
||||
.as_ref()
|
||||
.ok_or("An action target is required")?
|
||||
{
|
||||
report_panel_action::Target::Filter(action) => {
|
||||
if action.bindings.is_empty()
|
||||
|| action.bindings.len() > 32
|
||||
|| action
|
||||
.bindings
|
||||
.iter()
|
||||
.any(|binding| !filters.contains(&binding.filter_key))
|
||||
{
|
||||
return Err("Filter actions must target defined dashboard filters".into());
|
||||
}
|
||||
}
|
||||
report_panel_action::Target::Record(action) => {
|
||||
nonempty(&action.table_name, "Record table", 256)?;
|
||||
nonempty(&action.id_field, "Record ID field", 256)?;
|
||||
}
|
||||
report_panel_action::Target::Dashboard(action) => {
|
||||
nonempty(&action.dashboard_id, "Target dashboard", 64)?;
|
||||
if action.bindings.len() > 32 {
|
||||
return Err("A dashboard action can supply at most 32 filters".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut subjects = HashSet::new();
|
||||
for grant in &dashboard.grants {
|
||||
let subject = grant
|
||||
.subject
|
||||
.as_ref()
|
||||
.ok_or("A grant subject is required")?;
|
||||
let value = match subject {
|
||||
report_grant::Subject::Role(role) => role,
|
||||
report_grant::Subject::UserId(user) => user,
|
||||
};
|
||||
nonempty(value, "Grant subject", 128)?;
|
||||
if !subjects.insert(subject) {
|
||||
return Err("Duplicate report grant subject".into());
|
||||
}
|
||||
let mut capabilities = HashSet::new();
|
||||
for capability in &grant.capabilities {
|
||||
capabilities.insert(enum_value::<ReportCapability>(*capability, "capability")?);
|
||||
}
|
||||
if !capabilities.contains(&ReportCapability::View)
|
||||
|| capabilities.len() != grant.capabilities.len()
|
||||
{
|
||||
return Err("Report grants need View and unique capabilities".into());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn referenced_datasets(dashboard: &ReportDashboardDefinition) -> HashSet<&str> {
|
||||
dashboard
|
||||
.panels
|
||||
.iter()
|
||||
.map(|panel| panel.dataset_id.as_str())
|
||||
.chain(dashboard.filters.iter().filter_map(|filter| {
|
||||
filter
|
||||
.parameter
|
||||
.as_ref()?
|
||||
.lookup
|
||||
.as_ref()
|
||||
.map(|lookup| lookup.dataset_id.as_str())
|
||||
}))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn validate_dashboard_datasets(
|
||||
dashboard: &ReportDashboardDefinition,
|
||||
datasets: &[ReportDatasetSnapshot],
|
||||
) -> Result<(), String> {
|
||||
let datasets = datasets
|
||||
.iter()
|
||||
.map(|snapshot| {
|
||||
snapshot
|
||||
.definition
|
||||
.as_ref()
|
||||
.map(|definition| (snapshot.dataset_id.as_str(), definition))
|
||||
.ok_or("Missing dataset snapshot".to_string())
|
||||
})
|
||||
.collect::<Result<HashMap<_, _>, _>>()?;
|
||||
for id in referenced_datasets(dashboard) {
|
||||
if !datasets.contains_key(id) {
|
||||
return Err(format!("Dataset '{id}' is unavailable"));
|
||||
}
|
||||
}
|
||||
for filter in &dashboard.filters {
|
||||
let parameter = filter
|
||||
.parameter
|
||||
.as_ref()
|
||||
.ok_or("Missing filter parameter")?;
|
||||
for target in &filter.targets {
|
||||
let target_parameter = datasets
|
||||
.get(target.dataset_id.as_str())
|
||||
.and_then(|dataset| {
|
||||
dataset
|
||||
.parameters
|
||||
.iter()
|
||||
.find(|item| item.key == target.parameter_key)
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"Unknown filter target '{}.{}'",
|
||||
target.dataset_id, target.parameter_key
|
||||
)
|
||||
})?;
|
||||
if parameter.data_type != target_parameter.data_type
|
||||
|| parameter.multiple != target_parameter.multiple
|
||||
{
|
||||
return Err(format!(
|
||||
"Filter '{}' has an incompatible dataset parameter",
|
||||
parameter.key
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(lookup) = ¶meter.lookup {
|
||||
let dataset = datasets
|
||||
.get(lookup.dataset_id.as_str())
|
||||
.ok_or("Unknown lookup dataset")?;
|
||||
if !dataset.columns.iter().any(|column| {
|
||||
column.key == lookup.value_field && column.data_type == parameter.data_type
|
||||
}) || !dataset
|
||||
.columns
|
||||
.iter()
|
||||
.any(|column| column.key == lookup.label_field)
|
||||
{
|
||||
return Err(format!("Invalid lookup fields for '{}'", parameter.key));
|
||||
}
|
||||
if filter
|
||||
.targets
|
||||
.iter()
|
||||
.any(|target| target.dataset_id == lookup.dataset_id)
|
||||
{
|
||||
return Err("A lookup filter cannot filter its own choice dataset".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
for panel in &dashboard.panels {
|
||||
let dataset = datasets
|
||||
.get(panel.dataset_id.as_str())
|
||||
.ok_or("Unknown panel dataset")?;
|
||||
let columns: HashMap<_, _> = dataset
|
||||
.columns
|
||||
.iter()
|
||||
.map(|column| (column.key.as_str(), column))
|
||||
.collect();
|
||||
let kind: ReportPanelKind = enum_value(panel.kind, "panel kind")?;
|
||||
if panel.y_fields.iter().collect::<HashSet<_>>().len() != panel.y_fields.len()
|
||||
|| panel.table_fields.iter().collect::<HashSet<_>>().len() != panel.table_fields.len()
|
||||
{
|
||||
return Err("Panel field selections must be unique".into());
|
||||
}
|
||||
let field = |key: &str| {
|
||||
columns
|
||||
.get(key)
|
||||
.copied()
|
||||
.ok_or_else(|| format!("Panel '{}' refers to unknown field '{key}'", panel.title))
|
||||
};
|
||||
for key in [
|
||||
&panel.x_field,
|
||||
&panel.series_field,
|
||||
&panel.size_field,
|
||||
&panel.sort_field,
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|key| !key.is_empty())
|
||||
.chain(panel.y_fields.iter())
|
||||
.chain(panel.table_fields.iter())
|
||||
{
|
||||
field(key)?;
|
||||
}
|
||||
if kind != ReportPanelKind::Table {
|
||||
if panel.y_fields.is_empty() || panel.y_fields.len() > 16 {
|
||||
return Err("Charts need between 1 and 16 measures".into());
|
||||
}
|
||||
for key in &panel.y_fields {
|
||||
let kind: ReportDataType = enum_value(field(key)?.data_type, "measure type")?;
|
||||
if !matches!(kind, ReportDataType::Integer | ReportDataType::Decimal) {
|
||||
return Err("Chart measures must be numeric".into());
|
||||
}
|
||||
}
|
||||
if !matches!(kind, ReportPanelKind::Kpi | ReportPanelKind::Gauge) {
|
||||
field(&panel.x_field)?;
|
||||
}
|
||||
if matches!(
|
||||
kind,
|
||||
ReportPanelKind::Pie
|
||||
| ReportPanelKind::Donut
|
||||
| ReportPanelKind::Treemap
|
||||
| ReportPanelKind::Funnel
|
||||
| ReportPanelKind::Gauge
|
||||
| ReportPanelKind::Waterfall
|
||||
| ReportPanelKind::Heatmap
|
||||
| ReportPanelKind::Kpi
|
||||
) && panel.y_fields.len() != 1
|
||||
{
|
||||
return Err("This panel kind requires exactly one measure".into());
|
||||
}
|
||||
if kind == ReportPanelKind::Heatmap {
|
||||
field(&panel.series_field)?;
|
||||
}
|
||||
for key in [
|
||||
(kind == ReportPanelKind::Scatter).then_some(panel.x_field.as_str()),
|
||||
(!panel.size_field.is_empty()).then_some(panel.size_field.as_str()),
|
||||
].into_iter().flatten() {
|
||||
let data_type: ReportDataType = enum_value(field(key)?.data_type, "numeric field type")?;
|
||||
if !matches!(data_type, ReportDataType::Integer | ReportDataType::Decimal) {
|
||||
return Err("Scatter coordinates and size fields must be numeric".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
for action in &panel.actions {
|
||||
match action.target.as_ref().ok_or("Missing action target")? {
|
||||
report_panel_action::Target::Filter(action) => {
|
||||
for binding in &action.bindings {
|
||||
let source = field(&binding.column_key)?;
|
||||
let target = dashboard
|
||||
.filters
|
||||
.iter()
|
||||
.filter_map(|filter| filter.parameter.as_ref())
|
||||
.find(|parameter| parameter.key == binding.filter_key)
|
||||
.ok_or("Unknown action filter")?;
|
||||
if source.data_type != target.data_type {
|
||||
return Err("Action field and filter types must match".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
report_panel_action::Target::Record(action) => {
|
||||
if field(&action.id_field)?.data_type != ReportDataType::Integer as i32 {
|
||||
return Err("Record navigation requires an integer ID field".into());
|
||||
}
|
||||
}
|
||||
report_panel_action::Target::Dashboard(action) => {
|
||||
for binding in &action.bindings {
|
||||
field(&binding.column_key)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user