/
dimamir
/
rill
Обзор
Документация
Войти
/
dimamir
/
rill
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
runtime/validate.go
311 строк
11 KB
Benjamin Egelund-Müller
Prevent dimension names that match column names on ClickHouse if the dimension has a complex expression (#6063)
08 ноя 2024, 16:24
Не верифицирован
08 ноя 2024, 16:24
42096ea
Код
Авторство
О чём код?
package runtime import ( "context" "errors" "fmt" "slices" "strconv" "strings" "sync" runtimev1 "github.com/rilldata/rill/proto/gen/rill/runtime/v1" "github.com/rilldata/rill/runtime/drivers" "golang.org/x/sync/errgroup" ) const validateConcurrencyLimit = 10 type ValidateMetricsViewResult struct { TimeDimensionErr error DimensionErrs []IndexErr MeasureErrs []IndexErr OtherErrs []error } type IndexErr struct { Idx int Err error } func (r *ValidateMetricsViewResult) IsZero() bool { return r.TimeDimensionErr == nil && len(r.DimensionErrs) == 0 && len(r.MeasureErrs) == 0 && len(r.OtherErrs) == 0 } // Error returns a single error containing all validation errors. // If there are no errors, it returns nil. func (r *ValidateMetricsViewResult) Error() error { var errs []error errs = append(errs, r.TimeDimensionErr) for _, e := range r.DimensionErrs { errs = append(errs, e.Err) } for _, e := range r.MeasureErrs { errs = append(errs, e.Err) } errs = append(errs, r.OtherErrs...) // NOTE: errors.Join returns nil if all input errs are nil. return errors.Join(errs...) } // ValidateMetricsView validates a metrics view spec. // NOTE: If we need validation for more resources, we should consider moving it to the queries (or a dedicated validation package). func (r *Runtime) ValidateMetricsView(ctx context.Context, instanceID string, mv *runtimev1.MetricsViewSpec) (*ValidateMetricsViewResult, error) { ctrl, err := r.Controller(ctx, instanceID) if err != nil { return nil, err } olap, release, err := ctrl.AcquireOLAP(ctx, mv.Connector) if err != nil { return nil, err } defer release() // Create the result res := &ValidateMetricsViewResult{} // Check underlying table exists t, err := olap.InformationSchema().Lookup(ctx, mv.Database, mv.DatabaseSchema, mv.Table) if err != nil { if errors.Is(err, drivers.ErrNotFound) { res.OtherErrs = append(res.OtherErrs, fmt.Errorf("table %q does not exist", mv.Table)) return res, nil } return nil, fmt.Errorf("could not find table %q: %w", mv.Table, err) } cols := make(map[string]*runtimev1.StructType_Field, len(t.Schema.Fields)) for _, f := range t.Schema.Fields { cols[strings.ToLower(f.Name)] = f } // Check time dimension exists if mv.TimeDimension != "" { f, ok := cols[strings.ToLower(mv.TimeDimension)] if !ok { res.TimeDimensionErr = fmt.Errorf("timeseries %q is not a column in table %q", mv.TimeDimension, mv.Table) } else if f.Type.Code != runtimev1.Type_CODE_TIMESTAMP && f.Type.Code != runtimev1.Type_CODE_DATE { res.TimeDimensionErr = fmt.Errorf("timeseries %q is not a TIMESTAMP column", mv.TimeDimension) } } // Check security policy rules apply to fields that exist fields := make(map[string]bool, len(mv.Dimensions)+len(mv.Measures)) for _, d := range mv.Dimensions { fields[strings.ToLower(d.Name)] = true } for _, m := range mv.Measures { fields[strings.ToLower(m.Name)] = true } for _, rule := range mv.SecurityRules { fa := rule.GetFieldAccess() if fa == nil { continue } for _, f := range fa.Fields { if _, ok := fields[strings.ToLower(f)]; !ok { res.OtherErrs = append(res.OtherErrs, fmt.Errorf("field %q referenced in 'security' is not a dimension or measure", f)) } } } // ClickHouse specifically does not support using a column name as a dimension or measure name if the dimension or measure has an expression. // This is due to ClickHouse's aggressive substitution of aliases: https://github.com/ClickHouse/ClickHouse/issues/9715. if olap.Dialect() == drivers.DialectClickHouse { for _, d := range mv.Dimensions { if d.Expression == "" && !d.Unnest { continue } if d.Expression == d.Name { // If the expression exactly matches the name, substitution is not a problem. continue } if _, ok := cols[strings.ToLower(d.Name)]; ok { res.OtherErrs = append(res.OtherErrs, fmt.Errorf("invalid dimension %q: dimensions that use `expression` or `unnest` cannot have the same name as a column in the underlying table when backed by clickhouse", d.Name)) } } for _, m := range mv.Measures { if _, ok := cols[strings.ToLower(m.Name)]; ok { res.OtherErrs = append(res.OtherErrs, fmt.Errorf("invalid measure %q: measures cannot have the same name as a column in the underlying table when backed by clickhouse", m.Name)) } } } // For performance, attempt to validate all dimensions and measures at once err = validateAllDimensionsAndMeasures(ctx, olap, t, mv) if err != nil { // One or more dimension/measure expressions failed to validate. We need to check each one individually to provide useful errors. validateIndividualDimensionsAndMeasures(ctx, olap, t, mv, cols, res) } // Pinot does have any native support for time shift using time grain specifiers if olap.Dialect() == drivers.DialectPinot && (mv.FirstDayOfWeek > 1 || mv.FirstMonthOfYear > 1) { res.OtherErrs = append(res.OtherErrs, fmt.Errorf("time shift not supported for Pinot dialect, so FirstDayOfWeek and FirstMonthOfYear should be 1")) } // Check the default theme exists if mv.DefaultTheme != "" { _, err := ctrl.Get(ctx, &runtimev1.ResourceName{Kind: ResourceKindTheme, Name: mv.DefaultTheme}, false) if err != nil { if errors.Is(err, drivers.ErrNotFound) { res.OtherErrs = append(res.OtherErrs, fmt.Errorf("theme %q does not exist", mv.DefaultTheme)) } return nil, fmt.Errorf("could not find theme %q: %w", mv.DefaultTheme, err) } } return res, nil } // validateAllDimensionsAndMeasures validates all dimensions and measures with one query. It returns an error if any of the expressions are invalid. func validateAllDimensionsAndMeasures(ctx context.Context, olap drivers.OLAPStore, t *drivers.Table, mv *runtimev1.MetricsViewSpec) error { dialect := olap.Dialect() var dimExprs []string var unnestClauses []string var groupIndexes []string for idx, d := range mv.Dimensions { dimExpr, unnestClause := dialect.DimensionSelect(t.Database, t.DatabaseSchema, t.Name, d) dimExprs = append(dimExprs, dimExpr) if unnestClause != "" { unnestClauses = append(unnestClauses, unnestClause) } groupIndexes = append(groupIndexes, strconv.Itoa(idx+1)) } var metricExprs []string for _, m := range mv.Measures { if m.Type != runtimev1.MetricsViewSpec_MEASURE_TYPE_SIMPLE || m.Window != nil { // TODO: Validate advanced measures continue } metricExprs = append(metricExprs, "("+m.Expression+")") } var query string if len(dimExprs) == 0 && len(metricExprs) == 0 { // No metric and dimension, nothing to check return nil } if len(dimExprs) == 0 { // Only metrics query = fmt.Sprintf("SELECT 1, %s FROM %s GROUP BY 1", strings.Join(metricExprs, ","), olap.Dialect().EscapeTable(t.Database, t.DatabaseSchema, t.Name)) } else if len(metricExprs) == 0 { // No metrics query = fmt.Sprintf( "SELECT %s FROM %s %s GROUP BY %s", strings.Join(dimExprs, ","), olap.Dialect().EscapeTable(t.Database, t.DatabaseSchema, t.Name), strings.Join(unnestClauses, ""), strings.Join(groupIndexes, ","), ) } else { query = fmt.Sprintf( "SELECT %s, %s FROM %s %s GROUP BY %s", strings.Join(dimExprs, ","), strings.Join(metricExprs, ","), olap.Dialect().EscapeTable(t.Database, t.DatabaseSchema, t.Name), strings.Join(unnestClauses, ""), strings.Join(groupIndexes, ","), ) } err := olap.Exec(ctx, &drivers.Statement{ Query: query, DryRun: true, }) if err != nil { return fmt.Errorf("failed to validate dims and metrics: %w", err) } return nil } // validateIndividualDimensionsAndMeasures validates each dimension and measure individually. // It adds validation errors to the provided res. func validateIndividualDimensionsAndMeasures(ctx context.Context, olap drivers.OLAPStore, t *drivers.Table, mv *runtimev1.MetricsViewSpec, cols map[string]*runtimev1.StructType_Field, res *ValidateMetricsViewResult) { // Validate dimensions and measures concurrently with a limit of 10 concurrent validations var mu sync.Mutex var grp errgroup.Group grp.SetLimit(validateConcurrencyLimit) // Check dimension expressions are valid for idx, d := range mv.Dimensions { idx := idx d := d grp.Go(func() error { err := validateDimension(ctx, olap, t, d, cols) if err != nil { mu.Lock() defer mu.Unlock() res.DimensionErrs = append(res.DimensionErrs, IndexErr{ Idx: idx, Err: err, }) } return nil }) } // Check measure expressions are valid for idx, m := range mv.Measures { if m.Type != runtimev1.MetricsViewSpec_MEASURE_TYPE_SIMPLE || m.Window != nil { // TODO: Validate advanced measures continue } idx := idx m := m grp.Go(func() error { err := validateMeasure(ctx, olap, t, m) if err != nil { mu.Lock() defer mu.Unlock() res.MeasureErrs = append(res.MeasureErrs, IndexErr{ Idx: idx, Err: fmt.Errorf("invalid expression for measure %q: %w", m.Name, err), }) } return nil }) } // Wait for all validations to complete _ = grp.Wait() // Sort errors by index (for stable output) slices.SortFunc(res.DimensionErrs, func(a, b IndexErr) int { return a.Idx - b.Idx }) slices.SortFunc(res.MeasureErrs, func(a, b IndexErr) int { return a.Idx - b.Idx }) } // validateDimension validates a metrics view dimension. func validateDimension(ctx context.Context, olap drivers.OLAPStore, t *drivers.Table, d *runtimev1.MetricsViewSpec_DimensionV2, fields map[string]*runtimev1.StructType_Field) error { // Validate with a simple check if it's a column if d.Column != "" { if _, isColumn := fields[strings.ToLower(d.Column)]; !isColumn { return fmt.Errorf("failed to validate dimension %q: column %q not found in table", d.Name, d.Column) } if !d.Unnest { // for dimensions that have column and no unnest skip the expr validation since the above validation is enough return nil } } dialect := olap.Dialect() expr, unnestClause := dialect.DimensionSelect(t.Database, t.DatabaseSchema, t.Name, d) // Validate with a query if it's an expression err := olap.Exec(ctx, &drivers.Statement{ Query: fmt.Sprintf("SELECT %s FROM %s %s GROUP BY 1", expr, dialect.EscapeTable(t.Database, t.DatabaseSchema, t.Name), unnestClause), DryRun: true, }) if err != nil { return fmt.Errorf("failed to validate expression for dimension %q: %w", d.Name, err) } return nil } // validateMeasure validates a metrics view measure. func validateMeasure(ctx context.Context, olap drivers.OLAPStore, t *drivers.Table, m *runtimev1.MetricsViewSpec_MeasureV2) error { err := olap.Exec(ctx, &drivers.Statement{ Query: fmt.Sprintf("SELECT 1, (%s) FROM %s GROUP BY 1", m.Expression, olap.Dialect().EscapeTable(t.Database, t.DatabaseSchema, t.Name)), DryRun: true, }) return err }