something

This commit is contained in:
Priec
2026-09-16 08:31:52 +02:00
parent afdcd86833
commit eb17094b2f
10 changed files with 3380 additions and 1 deletions

View File

@@ -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;
}

View File

@@ -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
View 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(&parameter.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(&parameter.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 &parameter.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) = &parameter.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(&parameter.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(&parameter.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 112 and height 1601200".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) = &parameter.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(())
}