something
This commit is contained in:
Submodule client-gui2 updated: 140708f757...e991c7fec3
@@ -94,3 +94,404 @@ message AnalyticsResultBatch {
|
|||||||
uint64 row_count = 5;
|
uint64 row_count = 5;
|
||||||
uint64 elapsed_ms = 6;
|
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 grpc_error;
|
||||||
pub mod money;
|
pub mod money;
|
||||||
pub mod relationship;
|
pub mod relationship;
|
||||||
|
pub mod reporting;
|
||||||
pub mod script_operations;
|
pub mod script_operations;
|
||||||
pub mod system_column;
|
pub mod system_column;
|
||||||
pub mod typst_contract;
|
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(())
|
||||||
|
}
|
||||||
249
komp-app/src/analytics.rs
Normal file
249
komp-app/src/analytics.rs
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
use anyhow::{Result, bail, ensure};
|
||||||
|
use common::proto::komp_ac::analytics::{
|
||||||
|
AnalyticsResultBatch, AnalyticsResultColumn, AnalyticsValue, analytics_value::Kind,
|
||||||
|
};
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
const MAX_ROWS: usize = 10_000;
|
||||||
|
const MAX_RESULT_BYTES: usize = 32 * 1024 * 1024;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||||
|
#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
|
||||||
|
pub enum Cell {
|
||||||
|
Null,
|
||||||
|
Text(String),
|
||||||
|
Integer(String),
|
||||||
|
Unsigned(String),
|
||||||
|
Float(f64),
|
||||||
|
Boolean(bool),
|
||||||
|
Bytes(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<AnalyticsValue> for Cell {
|
||||||
|
type Error = anyhow::Error;
|
||||||
|
|
||||||
|
fn try_from(value: AnalyticsValue) -> Result<Self> {
|
||||||
|
Ok(match value.kind {
|
||||||
|
Some(Kind::NullValue(0)) => Self::Null,
|
||||||
|
Some(Kind::StringValue(value)) => Self::Text(value),
|
||||||
|
Some(Kind::Int64Value(value)) => Self::Integer(value.to_string()),
|
||||||
|
Some(Kind::Uint64Value(value)) => Self::Unsigned(value.to_string()),
|
||||||
|
Some(Kind::DoubleValue(value)) if value.is_finite() => Self::Float(value),
|
||||||
|
Some(Kind::BoolValue(value)) => Self::Boolean(value),
|
||||||
|
Some(Kind::BytesValue(value)) => {
|
||||||
|
Self::Bytes(value.iter().map(|byte| format!("{byte:02x}")).collect())
|
||||||
|
}
|
||||||
|
_ => bail!("Analytics returned an invalid or non-finite cell"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Cell {
|
||||||
|
fn size(&self) -> usize {
|
||||||
|
match self {
|
||||||
|
Self::Text(value)
|
||||||
|
| Self::Integer(value)
|
||||||
|
| Self::Unsigned(value)
|
||||||
|
| Self::Bytes(value) => value.len().saturating_add(32),
|
||||||
|
_ => 32,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Serialize)]
|
||||||
|
pub struct QueryResult {
|
||||||
|
pub columns: Vec<AnalyticsResultColumn>,
|
||||||
|
pub rows: Vec<Vec<Cell>>,
|
||||||
|
pub row_count: u64,
|
||||||
|
pub elapsed_ms: u64,
|
||||||
|
pub truncated: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Collector {
|
||||||
|
result: QueryResult,
|
||||||
|
bytes: usize,
|
||||||
|
finished: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Collector {
|
||||||
|
fn push(&mut self, batch: AnalyticsResultBatch) -> Result<()> {
|
||||||
|
ensure!(!self.finished, "Analytics sent data after completion");
|
||||||
|
if !batch.columns.is_empty() {
|
||||||
|
if self.result.columns.is_empty() {
|
||||||
|
self.result.columns = batch.columns;
|
||||||
|
} else {
|
||||||
|
ensure!(
|
||||||
|
self.result.columns == batch.columns,
|
||||||
|
"Analytics changed its result schema"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ensure!(
|
||||||
|
self.result.rows.len().saturating_add(batch.rows.len()) <= MAX_ROWS,
|
||||||
|
"Analytics result exceeds the client row limit"
|
||||||
|
);
|
||||||
|
for row in batch.rows {
|
||||||
|
ensure!(
|
||||||
|
row.values.len() == self.result.columns.len(),
|
||||||
|
"Analytics row does not match its schema"
|
||||||
|
);
|
||||||
|
let row: Vec<_> = row
|
||||||
|
.values
|
||||||
|
.into_iter()
|
||||||
|
.map(Cell::try_from)
|
||||||
|
.collect::<Result<_>>()?;
|
||||||
|
self.bytes = self
|
||||||
|
.bytes
|
||||||
|
.saturating_add(row.iter().map(Cell::size).sum::<usize>());
|
||||||
|
ensure!(
|
||||||
|
self.bytes <= MAX_RESULT_BYTES,
|
||||||
|
"Analytics result exceeds 32 MiB; narrow the query or filters"
|
||||||
|
);
|
||||||
|
self.result.rows.push(row);
|
||||||
|
}
|
||||||
|
if batch.is_final {
|
||||||
|
ensure!(
|
||||||
|
batch.row_count == self.result.rows.len() as u64,
|
||||||
|
"Analytics result is incomplete"
|
||||||
|
);
|
||||||
|
self.finished = true;
|
||||||
|
self.result.row_count = batch.row_count;
|
||||||
|
self.result.elapsed_ms = batch.elapsed_ms;
|
||||||
|
self.result.truncated = batch.truncated;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish(self) -> Result<QueryResult> {
|
||||||
|
ensure!(self.finished, "Analytics stream ended before completion");
|
||||||
|
Ok(self.result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn collect_result(
|
||||||
|
mut stream: tonic::Streaming<AnalyticsResultBatch>,
|
||||||
|
) -> Result<QueryResult> {
|
||||||
|
let mut collector = Collector::default();
|
||||||
|
while let Some(batch) = stream.message().await? {
|
||||||
|
collector.push(batch)?;
|
||||||
|
}
|
||||||
|
collector.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use common::proto::komp_ac::analytics::AnalyticsResultRow;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ipc_preserves_exact_values_and_distinguishes_null_from_empty_text() {
|
||||||
|
assert_eq!(
|
||||||
|
Cell::try_from(AnalyticsValue {
|
||||||
|
kind: Some(Kind::Int64Value(i64::MAX))
|
||||||
|
})
|
||||||
|
.unwrap(),
|
||||||
|
Cell::Integer("9223372036854775807".into())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Cell::try_from(AnalyticsValue {
|
||||||
|
kind: Some(Kind::Uint64Value(u64::MAX))
|
||||||
|
})
|
||||||
|
.unwrap(),
|
||||||
|
Cell::Unsigned("18446744073709551615".into())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Cell::try_from(AnalyticsValue {
|
||||||
|
kind: Some(Kind::StringValue("999999999999999999.99".into()))
|
||||||
|
})
|
||||||
|
.unwrap(),
|
||||||
|
Cell::Text("999999999999999999.99".into())
|
||||||
|
);
|
||||||
|
assert_ne!(
|
||||||
|
Cell::try_from(AnalyticsValue {
|
||||||
|
kind: Some(Kind::NullValue(0))
|
||||||
|
})
|
||||||
|
.unwrap(),
|
||||||
|
Cell::try_from(AnalyticsValue {
|
||||||
|
kind: Some(Kind::StringValue(String::new()))
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
);
|
||||||
|
assert!(Cell::try_from(AnalyticsValue { kind: None }).is_err());
|
||||||
|
assert!(
|
||||||
|
Cell::try_from(AnalyticsValue {
|
||||||
|
kind: Some(Kind::DoubleValue(f64::NAN))
|
||||||
|
})
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn data() -> AnalyticsResultBatch {
|
||||||
|
AnalyticsResultBatch {
|
||||||
|
columns: vec![AnalyticsResultColumn {
|
||||||
|
name: "amount".into(),
|
||||||
|
data_type: "Decimal128(20, 2)".into(),
|
||||||
|
}],
|
||||||
|
rows: vec![AnalyticsResultRow {
|
||||||
|
values: vec![AnalyticsValue {
|
||||||
|
kind: Some(Kind::StringValue("123.45".into())),
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn interrupted_stream_is_never_returned_as_a_successful_partial_report() {
|
||||||
|
let mut collector = Collector::default();
|
||||||
|
collector.push(data()).unwrap();
|
||||||
|
assert!(collector.finish().is_err());
|
||||||
|
let mut collector = Collector::default();
|
||||||
|
collector.push(data()).unwrap();
|
||||||
|
assert!(
|
||||||
|
collector
|
||||||
|
.push(AnalyticsResultBatch {
|
||||||
|
is_final: true,
|
||||||
|
row_count: 2,
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn completion_preserves_schema_and_truncation() {
|
||||||
|
let mut collector = Collector::default();
|
||||||
|
collector.push(data()).unwrap();
|
||||||
|
collector
|
||||||
|
.push(AnalyticsResultBatch {
|
||||||
|
is_final: true,
|
||||||
|
row_count: 1,
|
||||||
|
truncated: true,
|
||||||
|
elapsed_ms: 7,
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let result = collector.finish().unwrap();
|
||||||
|
assert_eq!(result.columns[0].data_type, "Decimal128(20, 2)");
|
||||||
|
assert_eq!(result.row_count, 1);
|
||||||
|
assert_eq!(result.elapsed_ms, 7);
|
||||||
|
assert!(result.truncated);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mismatched_schema_and_post_completion_batches_are_rejected() {
|
||||||
|
let mut collector = Collector::default();
|
||||||
|
let mut invalid = data();
|
||||||
|
invalid.rows[0].values.clear();
|
||||||
|
assert!(collector.push(invalid).is_err());
|
||||||
|
let mut collector = Collector::default();
|
||||||
|
collector
|
||||||
|
.push(AnalyticsResultBatch {
|
||||||
|
is_final: true,
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert!(collector.push(data()).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
use crate::search::SearchGrpc;
|
use crate::search::SearchGrpc;
|
||||||
|
mod reporting;
|
||||||
use anyhow::{Context, Result, anyhow};
|
use anyhow::{Context, Result, anyhow};
|
||||||
use crate::transport::{
|
use crate::transport::{
|
||||||
DEFAULT_GRPC_ENDPOINT, authenticated_request as request_with_auth_token, connect_channel,
|
DEFAULT_GRPC_ENDPOINT, authenticated_request as request_with_auth_token, connect_channel,
|
||||||
|
|||||||
134
komp-app/src/grpc/reporting.rs
Normal file
134
komp-app/src/grpc/reporting.rs
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
use super::GrpcClient;
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use common::proto::komp_ac::analytics::{reporting_service_client::ReportingServiceClient, *};
|
||||||
|
|
||||||
|
impl GrpcClient {
|
||||||
|
pub async fn list_report_assets(
|
||||||
|
&mut self,
|
||||||
|
request: ListReportAssetsRequest,
|
||||||
|
) -> Result<ListReportAssetsResponse> {
|
||||||
|
let request = self.authenticated_request(request)?;
|
||||||
|
let response = ReportingServiceClient::new(self.channel.clone())
|
||||||
|
.list_assets(request)
|
||||||
|
.await
|
||||||
|
.context("gRPC ReportingService list_assets call failed")?;
|
||||||
|
Ok(response.into_inner())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_report_asset(
|
||||||
|
&mut self,
|
||||||
|
request: GetReportAssetRequest,
|
||||||
|
) -> Result<ReportAsset> {
|
||||||
|
let request = self.authenticated_request(request)?;
|
||||||
|
let response = ReportingServiceClient::new(self.channel.clone())
|
||||||
|
.get_asset(request)
|
||||||
|
.await
|
||||||
|
.context("gRPC ReportingService get_asset call failed")?;
|
||||||
|
Ok(response.into_inner())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn save_report_draft(
|
||||||
|
&mut self,
|
||||||
|
request: SaveReportDraftRequest,
|
||||||
|
) -> Result<ReportAsset> {
|
||||||
|
let request = self.authenticated_request(request)?;
|
||||||
|
let response = ReportingServiceClient::new(self.channel.clone())
|
||||||
|
.save_draft(request)
|
||||||
|
.await
|
||||||
|
.context("gRPC ReportingService save_draft call failed")?;
|
||||||
|
Ok(response.into_inner())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn publish_report(&mut self, request: PublishReportRequest) -> Result<ReportAsset> {
|
||||||
|
let request = self.authenticated_request(request)?;
|
||||||
|
let response = ReportingServiceClient::new(self.channel.clone())
|
||||||
|
.publish(request)
|
||||||
|
.await
|
||||||
|
.context("gRPC ReportingService publish call failed")?;
|
||||||
|
Ok(response.into_inner())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_report_versions(
|
||||||
|
&mut self,
|
||||||
|
request: ReportAssetRef,
|
||||||
|
) -> Result<ListReportVersionsResponse> {
|
||||||
|
let request = self.authenticated_request(request)?;
|
||||||
|
let response = ReportingServiceClient::new(self.channel.clone())
|
||||||
|
.list_versions(request)
|
||||||
|
.await
|
||||||
|
.context("gRPC ReportingService list_versions call failed")?;
|
||||||
|
Ok(response.into_inner())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn restore_report_draft(
|
||||||
|
&mut self,
|
||||||
|
request: RestoreReportDraftRequest,
|
||||||
|
) -> Result<ReportAsset> {
|
||||||
|
let request = self.authenticated_request(request)?;
|
||||||
|
let response = ReportingServiceClient::new(self.channel.clone())
|
||||||
|
.restore_draft(request)
|
||||||
|
.await
|
||||||
|
.context("gRPC ReportingService restore_draft call failed")?;
|
||||||
|
Ok(response.into_inner())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_report_archived(
|
||||||
|
&mut self,
|
||||||
|
request: SetReportArchivedRequest,
|
||||||
|
) -> Result<ReportAsset> {
|
||||||
|
let request = self.authenticated_request(request)?;
|
||||||
|
let response = ReportingServiceClient::new(self.channel.clone())
|
||||||
|
.set_archived(request)
|
||||||
|
.await
|
||||||
|
.context("gRPC ReportingService set_archived call failed")?;
|
||||||
|
Ok(response.into_inner())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_report_personal_views(
|
||||||
|
&mut self,
|
||||||
|
request: ReportAssetRef,
|
||||||
|
) -> Result<GetReportPersonalViewsResponse> {
|
||||||
|
let request = self.authenticated_request(request)?;
|
||||||
|
let response = ReportingServiceClient::new(self.channel.clone())
|
||||||
|
.get_personal_views(request)
|
||||||
|
.await
|
||||||
|
.context("gRPC ReportingService get_personal_views call failed")?;
|
||||||
|
Ok(response.into_inner())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn save_report_personal_view(
|
||||||
|
&mut self,
|
||||||
|
request: SaveReportPersonalViewRequest,
|
||||||
|
) -> Result<ReportPersonalView> {
|
||||||
|
let request = self.authenticated_request(request)?;
|
||||||
|
let response = ReportingServiceClient::new(self.channel.clone())
|
||||||
|
.save_personal_view(request)
|
||||||
|
.await
|
||||||
|
.context("gRPC ReportingService save_personal_view call failed")?;
|
||||||
|
Ok(response.into_inner())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_report_personal_view(
|
||||||
|
&mut self,
|
||||||
|
request: DeleteReportPersonalViewRequest,
|
||||||
|
) -> Result<ReportMutationResult> {
|
||||||
|
let request = self.authenticated_request(request)?;
|
||||||
|
let response = ReportingServiceClient::new(self.channel.clone())
|
||||||
|
.delete_personal_view(request)
|
||||||
|
.await
|
||||||
|
.context("gRPC ReportingService delete_personal_view call failed")?;
|
||||||
|
Ok(response.into_inner())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn execute_report_dataset(
|
||||||
|
&mut self,
|
||||||
|
request: ExecuteReportDatasetRequest,
|
||||||
|
) -> Result<tonic::Streaming<AnalyticsResultBatch>> {
|
||||||
|
let request = self.authenticated_request(request)?;
|
||||||
|
let response = ReportingServiceClient::new(self.channel.clone())
|
||||||
|
.execute_dataset(request)
|
||||||
|
.await
|
||||||
|
.context("gRPC ReportingService execute_dataset call failed")?;
|
||||||
|
Ok(response.into_inner())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
//! owns client behavior that must not drift between them.
|
//! owns client behavior that must not drift between them.
|
||||||
|
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
|
pub mod analytics;
|
||||||
pub mod csv;
|
pub mod csv;
|
||||||
pub mod grpc;
|
pub mod grpc;
|
||||||
pub mod import_export;
|
pub mod import_export;
|
||||||
|
|||||||
Reference in New Issue
Block a user