{"record":{"id":"22f770ccaec25516","repo":"octobercms/october","slug":"invalid-aggregate-function-22f770","errorCode":null,"errorMessage":"Invalid aggregate function: ","messagePattern":"Invalid aggregate function: ","errorType":"exception","errorClass":"SystemException","httpStatus":null,"severity":"error","filePath":"modules/dashboard/classes/ReportQueryBuilder.php","lineNumber":870,"sourceCode":"\n    /**\n     * getAggregateSql returns SQL template for aggregate function\n     *\n     * @param string $function\n     * @return string\n     */\n    protected function getAggregateSql(string $function): string\n    {\n        return match ($function) {\n            ReportMetric::AGGREGATE_AVG => 'avg(%1$s)',\n            ReportMetric::AGGREGATE_COUNT => 'count(%1$s)',\n            ReportMetric::AGGREGATE_MAX => 'max(%1$s)',\n            ReportMetric::AGGREGATE_MIN => 'min(%1$s)',\n            ReportMetric::AGGREGATE_SUM => 'sum(%1$s)',\n            ReportMetric::AGGREGATE_COUNT_DISTINCT => 'count(distinct %1$s)',\n            ReportMetric::AGGREGATE_COUNT_DISTINCT_NOT_NULL => 'count(distinct case when %1$s is not null then %1$s end)',\n            ReportMetric::AGGREGATE_NONE => '%1$s',\n            default => throw new SystemException('Invalid aggregate function: ' . $function)\n        };\n    }\n\n    /**\n     * applyDateFilters applies date range or timestamp filtering\n     *\n     * @param QueryBuilder $query\n     */\n    protected function applyDateFilters(QueryBuilder $query): void\n    {\n        if ($this->dateColumn && $this->dateStart !== null) {\n            $query->whereBetween($this->dateColumn, [\n                $this->dateStart->startOfDay()->toDateTimeString(),\n                $this->dateEnd->endOfDay()->toDateTimeString()\n            ]);\n        }\n\n        if ($this->timestampColumn && $this->startTimestamp !== null) {","sourceCodeStart":852,"sourceCodeEnd":888,"githubUrl":"https://github.com/octobercms/october/blob/b608633a7e8922487d91a8161499020121c3b3bf/modules/dashboard/classes/ReportQueryBuilder.php#L852-L888","documentation":"ReportQueryBuilder::getAggregateSql(string $function) maps a metric's aggregate function to its SQL template via a match expression; any function string outside the eight AGGREGATE_* values hits the default arm and throws SystemException('Invalid aggregate function: <function>'). Under normal flow this is unreachable because ReportMetric's constructor already whitelists the function — hitting it means a metric reached the builder without constructor validation, or the function was mutated/constructed bypassing the setters.","triggerScenarios":"Subclass or test constructing a half-initialized ReportMetric (e.g. via reflection or by unserializing cached data) and passing it to applyMetrics(); a metric object whose $aggregateFunction property was changed after construction; calling getAggregateSql() directly with an arbitrary string like 'median' while extending the builder.","commonSituations":"Cached/serialized metrics restored from an old cache entry created before a constant rename; custom ReportQueryBuilder subclass that injects its own function names; version skew between a cached dashboard definition and updated AGGREGATE constants.","solutions":["Re-create the metric properly through the constructor with an AGGREGATE_* constant instead of rehydrating/mutating one.","Clear stale caches (dashboard/report caches, config cache) after upgrading or renaming aggregate constants.","If extending the builder, override getAggregateSql() and add your template to the match (plus your own validation upstream) rather than passing unknown strings.","Log the offending function value from the exception message to identify which metric code carries it."],"exampleFix":"// before (extending builder with custom aggregate)\n$this->getAggregateSql('median'); // throws\n\n// after\nprotected function getAggregateSql(string $function): string\n{\n    return match ($function) {\n        'median' => 'percentile_cont(0.5) within group (order by %1$s)',\n        default => parent::getAggregateSql($function),\n    };\n}","handlingStrategy":"validation","validationCode":"// Only feed metrics to the builder that were built via the constructor\nforeach ($metrics as $metric) {\n    if (!$metric instanceof ReportMetric) {\n        throw new InvalidArgumentException('All metrics must be ReportMetric instances.');\n    }\n}\n$builder->applyMetrics($metrics);","typeGuard":"function hasValidAggregate(ReportMetric $metric): bool\n{\n    return in_array($metric->getAggregateFunction(), [\n        ReportMetric::AGGREGATE_SUM, ReportMetric::AGGREGATE_AVG,\n        ReportMetric::AGGREGATE_MIN, ReportMetric::AGGREGATE_MAX,\n        ReportMetric::AGGREGATE_COUNT, ReportMetric::AGGREGATE_NONE,\n        ReportMetric::AGGREGATE_COUNT_DISTINCT, ReportMetric::AGGREGATE_COUNT_DISTINCT_NOT_NULL,\n    ], true);\n}","tryCatchPattern":"try {\n    $rows = $builder->buildQuery()->get();\n} catch (SystemException $e) {\n    if (str_contains($e->getMessage(), 'Invalid aggregate function')) {\n        // a cached/serialized metric is stale — rebuild metrics from definitions and retry once\n        $metrics = $dataSource->getAvailableMetrics(true); // force fresh\n        $builder->applyMetrics($metrics);\n        $rows = $builder->buildQuery()->get();\n    } else {\n        throw $e;\n    }\n}","preventionTips":["Never rehydrate ReportMetric objects from cache without revalidating the aggregate function.","Clear report/dashboard caches after upgrading modules that rename AGGREGATE constants.","Extend getAggregateSql() in a subclass if you genuinely need custom aggregates, and validate upstream."],"tags":["dashboard","report-query-builder","aggregate-functions","enum-value-not-allowed"],"backgroundTag":"enum-value-not-allowed","analyzedSha":"b608633a7e8922487d91a8161499020121c3b3bf","analyzedAt":"2026-08-21T04:24:57.515Z","schemaVersion":2},"datasetVersion":"2026-08-21T11:28:35.574Z"}