From f2e42cecfe5b99f7ee67a15be060df8b578a9ac2 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Tue, 8 Sep 2026 01:32:52 +0200 Subject: [PATCH] Release govoplan-reporting v0.1.21: preserve calculated measure bindings --- pyproject.toml | 4 +- src/govoplan_reporting/__init__.py | 2 +- src/govoplan_reporting/backend/manifest.py | 11 ++- .../backend/postgres_planner.py | 4 +- tests/test_postgres_bind_names.py | 96 +++++++++++++++++++ webui/package.json | 4 +- webui/scripts/test-interface-pattern.mjs | 2 + .../src/features/reporting/ReportingPage.tsx | 19 ++-- 8 files changed, 125 insertions(+), 17 deletions(-) create mode 100644 tests/test_postgres_bind_names.py diff --git a/pyproject.toml b/pyproject.toml index 6392192..23e3ac5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta" [project] name = "govoplan-reporting" -version = "0.1.20" +version = "0.1.21" description = "GovOPlaN governed reporting and semantic BI module." readme = "README.md" requires-python = ">=3.12" license = { text = "AGPL-3.0-or-later" } authors = [{ name = "GovOPlaN" }] dependencies = [ - "govoplan-core>=0.1.37", + "govoplan-core>=0.1.45", "govoplan-access>=0.1.18", ] diff --git a/src/govoplan_reporting/__init__.py b/src/govoplan_reporting/__init__.py index 5850683..2e35a22 100644 --- a/src/govoplan_reporting/__init__.py +++ b/src/govoplan_reporting/__init__.py @@ -1,3 +1,3 @@ """GovOPlaN Reporting module.""" -__version__ = "0.1.20" +__version__ = "0.1.21" diff --git a/src/govoplan_reporting/backend/manifest.py b/src/govoplan_reporting/backend/manifest.py index bbcfeca..090afd5 100644 --- a/src/govoplan_reporting/backend/manifest.py +++ b/src/govoplan_reporting/backend/manifest.py @@ -83,7 +83,7 @@ from govoplan_reporting.backend.search_source import create_reporting_search_sou MODULE_ID = "reporting" MODULE_NAME = "Reporting" -MODULE_VERSION = "0.1.20" +MODULE_VERSION = "0.1.21" def _permission(scope: str, label: str, description: str) -> PermissionDefinition: @@ -603,8 +603,10 @@ manifest = ModuleManifest( "aggregations, typed expressions, filters, pivots, saved views, chart models, " "schedules, exports, and publication providers replace unchecked SQL in the " "presentation layer. PostgreSQL executes bounded semantic plans when available. " + "Calculated measure keys may contain the documented dots and hyphens, including in nested references; generated bind names remain internal and values remain parameters, not SQL fragments. " "Signed drill contexts reauthorize contributor rows, and Files/Mail publication " - "adapters retain idempotent evidence. A dataset may pin one successful published Dataflow run, which is read from its exact Datasource materialization after both source boundaries reauthorize the current principal. Dataflow and module read models remain source owners." + "adapters retain idempotent evidence. A dataset may pin one successful published Dataflow run, which is read from its exact Datasource materialization after both source boundaries reauthorize the current principal. Dataflow and module read models remain source owners. " + "The contributor drill-down action stays in a shared action column at the right edge of horizontally scrolled results; opening it still reauthorizes every contributor." ), layer="available", documentation_types=("admin", "user"), @@ -663,12 +665,13 @@ manifest = ModuleManifest( "Filter, Pivotierungen, gespeicherte Ansichten, Diagrammmodelle, Zeitpläne, " "Exporte und Veröffentlichungsanbieter ersetzen ungeprüftes SQL in der " "Darstellungsschicht. PostgreSQL führt begrenzte semantische Pläne aus, sofern " - "verfügbar. Signierte Drilldown-Kontexte autorisieren beitragende Zeilen erneut; " + "verfügbar. Kennungen berechneter Kennzahlen dürfen auch in verschachtelten Verweisen die vorgesehenen Punkte und Bindestriche enthalten; " + "erzeugte Bindungsnamen bleiben intern, und Werte bleiben Parameter statt SQL-Fragmente. Signierte Drilldown-Kontexte autorisieren beitragende Zeilen erneut; " "Adapter für Dateien und Mail bewahren idempotente Nachweise. Ein Datensatz kann " "genau eine erfolgreiche veröffentlichte Dataflow-Ausführung fixieren, die nach " "erneuter Autorisierung beider Quellgrenzen aus ihrer exakten Datasource-" "Materialisierung gelesen wird. Dataflow und die Lesemodelle der Module bleiben " - "führende Quellen." + "führende Quellen. Die Aktion zum Aufschlüsseln beitragender Zeilen bleibt in einer gemeinsamen Aktionsspalte am rechten Rand horizontal gescrollter Ergebnisse; beim Öffnen wird jeder Beitrag erneut autorisiert." ), } }, diff --git a/src/govoplan_reporting/backend/postgres_planner.py b/src/govoplan_reporting/backend/postgres_planner.py index 4376f0f..3a9a0e2 100644 --- a/src/govoplan_reporting/backend/postgres_planner.py +++ b/src/govoplan_reporting/backend/postgres_planner.py @@ -344,7 +344,9 @@ def _calculated_sql( return _calculated_sql( target.expression, parameters, - prefix + "_" + reference, + # The expression position already makes this prefix unique. Model + # keys may contain dots or hyphens, which are not SQL bind names. + prefix + "_ref", measures=measures, stack=(*stack, reference), ) diff --git a/tests/test_postgres_bind_names.py b/tests/test_postgres_bind_names.py new file mode 100644 index 0000000..a1de542 --- /dev/null +++ b/tests/test_postgres_bind_names.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +import unittest + +from sqlalchemy import text +from sqlalchemy.dialects import postgresql + +from govoplan_reporting.backend.postgres_planner import compile_postgres_query +from govoplan_reporting.backend.schemas import ( + DatasetDefinition, + ReportQuery, + SemanticModelDefinition, +) + + +class PostgresBindNameTests(unittest.TestCase): + def test_allowed_measure_key_punctuation_never_becomes_bind_parameter_syntax( + self, + ) -> None: + dataset = DatasetDefinition( + source_kind="static", + source_ref="fixture", + static_rows=[{"value": 10}], + purpose="Bound parameter fixture", + ) + semantic = SemanticModelDefinition.model_validate( + { + "dataset_id": "fixture", + "dataset_revision": 1, + "measures": [ + { + "key": "base", + "label": "Base", + "aggregation": "sum", + "field": "value", + }, + { + "key": "extra-cost", + "label": "Extra", + "aggregation": "calculated", + "expression": { + "op": "add", + "args": [ + {"op": "measure", "ref": "base"}, + {"op": "literal", "value": 5}, + ], + }, + }, + { + "key": "tax.factor", + "label": "Tax", + "aggregation": "calculated", + "expression": { + "op": "multiply", + "args": [ + {"op": "measure", "ref": "base"}, + {"op": "literal", "value": 9}, + ], + }, + }, + { + "key": "grand-total", + "label": "Total", + "aggregation": "calculated", + "expression": { + "op": "add", + "args": [ + {"op": "measure", "ref": "extra-cost"}, + {"op": "measure", "ref": "tax.factor"}, + ], + }, + }, + ], + } + ) + plan = compile_postgres_query( + dataset, + semantic, + ReportQuery(measures=["extra-cost", "tax.factor", "grand-total"]), + ) + binds = text(plan.sql).compile(dialect=postgresql.dialect()).params + self.assertEqual( + set(plan.parameters) | {"rows_json", "result_limit", "result_offset"}, + set(binds), + ) + self.assertTrue( + all("-" not in name and "." not in name for name in plan.parameters) + ) + self.assertIn('AS "extra-cost"', plan.sql) + self.assertIn('AS "tax.factor"', plan.sql) + self.assertEqual(2, list(plan.parameters.values()).count(5)) + self.assertEqual(2, list(plan.parameters.values()).count(9)) + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/package.json b/webui/package.json index ce1f416..e7ff031 100644 --- a/webui/package.json +++ b/webui/package.json @@ -1,6 +1,6 @@ { "name": "@govoplan/reporting-webui", - "version": "0.1.20", + "version": "0.1.21", "private": true, "type": "module", "main": "src/index.ts", @@ -17,7 +17,7 @@ "test:interface-pattern": "node scripts/test-interface-pattern.mjs" }, "peerDependencies": { - "@govoplan/core-webui": "^0.1.18", + "@govoplan/core-webui": "^0.1.45", "lucide-react": "^1.23.0", "react": ">=19.2.7 <20", "react-dom": ">=19.2.7 <20", diff --git a/webui/scripts/test-interface-pattern.mjs b/webui/scripts/test-interface-pattern.mjs index 81bb7bc..e377f55 100644 --- a/webui/scripts/test-interface-pattern.mjs +++ b/webui/scripts/test-interface-pattern.mjs @@ -10,6 +10,8 @@ assert.ok(page.includes("PageScrollViewport"), "Reporting owns bounded catalogue assert.ok(page.includes("DataGrid"), "Tabular report results use the shared grid"); assert.ok(page.includes(" setPage(0), [execution.execution_id]); const columns = useMemo>[]>(() => { - const result = execution.schema.map((field) => ({ + const result: DataGridColumn>[] = execution.schema.map((field) => ({ id: field.name, header: humanize(field.name), width: "1fr", @@ -559,16 +560,20 @@ function ReportTable({ execution, onDrill }: { execution: ReportExecution; onDri result.push({ id: "drill", header: "Detail", + columnType: "actions", + sticky: "end", + resizable: false, + align: "right", width: 74, minWidth: 74, maxWidth: 74, render: (row) => ( - } - variant="ghost" - onClick={() => onDrill(row)} - /> +