5 Commits
Author SHA1 Message Date
zemion 618f10fe89 fix(packaging): expose immutable WebUI Git package for v0.1.24
Module Package Release / publish-packages (push) Successful in 12s
2026-09-08 02:06:09 +02:00
zemion f222be63b2 Release govoplan-dataflow v0.1.24: bound preview allocation and restore editor imports 2026-09-08 01:32:30 +02:00
zemion 2c97223fb4 fix(webui): bind destructive dataflow controls to help
Module Package Release / publish-packages (push) Successful in 12s
2026-08-24 11:36:36 +02:00
zemion 5b989a7f6c docs: complete German structured documentation
Module Package Release / publish-packages (push) Successful in 12s
2026-08-24 01:15:33 +02:00
zemion 08c1ecbf81 docs(dataflow): complete German reference coverage
Module Package Release / publish-packages (push) Successful in 12s
2026-08-23 21:09:36 +02:00
15 changed files with 522 additions and 26 deletions
+18
View File
@@ -213,3 +213,21 @@ npm run test:structure
The implementation epic is The implementation epic is
[`govoplan-dataflow#1`](https://git.add-ideas.de/GovOPlaN/govoplan-dataflow/issues/1). [`govoplan-dataflow#1`](https://git.add-ideas.de/GovOPlaN/govoplan-dataflow/issues/1).
## Git-source WebUI package
The repository root exposes `@govoplan/dataflow-webui` for Git-tagged release
dependencies. It mirrors the owning `webui/package.json` version, public
TypeScript/CSS exports and peer requirements, with entry paths under
`webui/src`. Consumers provide the shared Core/React peers; the facade runs no
development or install scripts. The source archive contains `webui/src`, this
README and any repository license file. Run module development checks from `webui/`; Python
installation remains governed by `pyproject.toml`.
Das Repository stellt `@govoplan/dataflow-webui` am Wurzelpfad für versionierte
Git-Abhängigkeiten bereit. Version, öffentliche TypeScript-/CSS-Exporte und
Peer-Anforderungen entsprechen `webui/package.json`; die Einstiegspfade liegen
unter `webui/src`. Gemeinsame Core-/React-Peers stellt die einbindende Anwendung
bereit. Die Fassade führt keine Entwicklungs- oder Installationsskripte aus.
Entwicklungsprüfungen bleiben in `webui/`, die Python-Installation weiterhin in
`pyproject.toml` definiert.
+35
View File
@@ -0,0 +1,35 @@
{
"name": "@govoplan/dataflow-webui",
"version": "0.1.24",
"private": true,
"type": "module",
"main": "webui/src/index.ts",
"module": "webui/src/index.ts",
"types": "webui/src/index.ts",
"exports": {
".": {
"types": "./webui/src/index.ts",
"import": "./webui/src/index.ts"
},
"./styles/dataflow.css": "./webui/src/styles/dataflow.css"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.45",
"@xyflow/react": "^12.11.2",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
"react-router": ">=8.3.0 <9",
"typescript": "^5.7.2"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
},
"files": [
"webui/src",
"README.md",
"LICENSE"
]
}
+2 -2
View File
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-dataflow" name = "govoplan-dataflow"
version = "0.1.20" version = "0.1.24"
description = "Governed graphical and SQL data pipelines for GovOPlaN." description = "Governed graphical and SQL data pipelines for GovOPlaN."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
license = "AGPL-3.0-or-later" license = "AGPL-3.0-or-later"
authors = [{ name = "GovOPlaN" }] authors = [{ name = "GovOPlaN" }]
dependencies = [ dependencies = [
"govoplan-core>=0.1.18", "govoplan-core>=0.1.45",
"sqlglot>=30.14,<31", "sqlglot>=30.14,<31",
] ]
+1 -1
View File
@@ -1,3 +1,3 @@
from __future__ import annotations from __future__ import annotations
__version__ = "0.1.20" __version__ = "0.1.24"
+1 -1
View File
@@ -19,6 +19,7 @@ from govoplan_dataflow.backend.operator_registry import (
OperatorExecutionContext, OperatorExecutionContext,
OperatorExecutionResult, OperatorExecutionResult,
) )
from govoplan_dataflow.backend.preview_limits import MAX_RESULT_BYTES
from govoplan_dataflow.backend.schemas import ( from govoplan_dataflow.backend.schemas import (
DataflowDiagnostic, DataflowDiagnostic,
GraphNode, GraphNode,
@@ -32,7 +33,6 @@ from govoplan_dataflow.backend.subflows import substitute_parameters
EXECUTOR_VERSION = "dataflow-preview-v2" EXECUTOR_VERSION = "dataflow-preview-v2"
MAX_EXECUTION_SECONDS = 2.0 MAX_EXECUTION_SECONDS = 2.0
MAX_RESULT_BYTES = 1_000_000
MAX_SOURCE_ROWS = 250 MAX_SOURCE_ROWS = 250
MAX_INTERMEDIATE_ROWS = 10_000 MAX_INTERMEDIATE_ROWS = 10_000
@@ -12,6 +12,8 @@ import sqlglot
from sqlglot import exp from sqlglot import exp
from sqlglot.errors import ParseError from sqlglot.errors import ParseError
from govoplan_dataflow.backend.preview_limits import MAX_RESULT_BYTES
ExpressionDataType = Literal[ ExpressionDataType = Literal[
"unknown", "unknown",
@@ -438,6 +440,14 @@ def _evaluate_pad(expression: exp.Expression, row: dict[str, Any]) -> str | None
target_length = int(_evaluate(expression.expression, row)) target_length = int(_evaluate(expression.expression, row))
if target_length < 0: if target_length < 0:
raise ValueError("Padding length cannot be negative.") raise ValueError("Padding length cannot be negative.")
# Every character takes at least one serialized byte. Enforce the existing
# node budget before padding allocates memory, including when an outer
# LENGTH/SUBSTRING would otherwise conceal the oversized intermediate value.
# The node's final byte check still accounts for Unicode and JSON overhead.
if target_length > MAX_RESULT_BYTES:
raise ExpressionError(
f"Padding length exceeds the {MAX_RESULT_BYTES:,}-byte preview result limit."
)
source = str(value) source = str(value)
if len(source) >= target_length: if len(source) >= target_length:
return source[:target_length] return source[:target_length]
@@ -0,0 +1,70 @@
from __future__ import annotations
from dataclasses import replace
from typing import Iterable
from govoplan_core.core.modules import DocumentationTopic
_TRANSLATIONS = {
"dataflow.data-subject-requests": {
"title": "Datenschutzanfragen zu Dataflow-Daten",
"summary": "Gespeicherte Transformationsdetails minimieren, ohne abgeleitete Datenflüsse als führende Betroffenendaten zu behandeln.",
"body": (
"Dataflow gleicht exakte mandantenbezogene Pipeline-, Revisions-, Abgleich-, Lauf-, Bereitstellungs-, Trigger- und Zustellkennungen sowie minimierte Konto-, Identitäts- und Mitgliedschaftszuordnungen ab. Ergebnisse enthalten niemals Graphen, SQL, Anfrage- oder Ereignisinhalte, Abgleichkorrekturen, Autorisierungssnapshots, Provenienztexte, Fehler, Quelldetails, Hashwerte, Zugangsdaten oder Ausgabezeilen. Dataflow durchsucht beliebige Transformationsinhalte nicht nach Personen; das führende Eingabemodul muss Betroffenendaten auffinden und berichtigen. Eindeutig bestimmte abgeschlossene Lauf- und Zustelldetails können idempotent minimiert und personenbezogene Automatisierungsbefugnisse deaktiviert und widerrufen werden. Definitionen, Abgleichnachweise, aktive Arbeiten, Bereitstellungen, umfassende Pipeline-Pakete, veröffentlichte Datasource-Ausgaben und institutionelle Zuordnungen benötigen eine autorisierte Prüfung oder Aufbewahrung. Nach der Prüfung sind die Quellen zu berichtigen und Ableitungen in Datasources, Search und Reporting zu aktualisieren."
),
},
"dataflow.module-boundary": {
"title": "Modulgrenze von Dataflow",
"summary": "Versionierte tabellarische Transformationen grafisch oder mit eingeschränktem SQL bearbeiten.",
"body": (
"Dataflow verantwortet kanonische Pipeline-Graphen, unveränderliche Revisionen, Validierung, eingeschränkte SQL-Kompilierung, Vorschau- und Laufdiagnosen sowie Herkunftsreferenzen. Datasources verantwortet den gesteuerten Katalog und Materialisierungen, Connectors den externen Abruf und Zugangsdaten, Reporting die analytische Darstellung und Exporte, Workflow die Orchestrierung und menschliche Übergaben und Risk Compliance die Sanktionsprüfung und Richtliniengrenzen. Benutzer-SQL wird in freigegebene Transformationen übersetzt und nie ungeprüft an eine Datenbank weitergereicht. "
"Das Öffnen oder Neuladen von Dataflow startet keine Pipeline. Kann der Editor nach einer Entwicklungsaktualisierung nicht geladen werden, sichern Sie ungespeicherte Arbeit vor dem Neuladen des Browsers. Administratoren sollten Fehler beim Laden von Oberflächendateien von Fehlern der Pipeline-API oder Zugriffsfehlern unterscheiden. Entwicklungs- und Browser-Konformitätsserver verwenden getrennte Abhängigkeitscaches; ein älterer Server muss nach dieser Konfigurationsaktualisierung gegebenenfalls neu gestartet werden."
),
},
"dataflow.reference.nodes-and-expressions": {
"title": "Dataflow-Knoten und Ausdrücke",
"summary": "Typisierte Knoteneingaben, Ausdrücke, Schemafortschreibung und begrenzte Zwischenergebnisse verstehen.",
"body": (
"Jeder Graphknoten definiert typisierte Eingaben, Konfiguration, Ausgabeschema und Validierungsregeln. Quellknoten binden Inline-Inhalte oder gesteuerte Datasource-Referenzen; Verknüpfungs-, Filter-, Transformations-, Qualitäts-, Abgleich-, Teilfluss- und Ausgabeknoten bleiben im kanonischen Graphen ausdrücklich sichtbar. Wiederverwendbare Teilflüsse wählen eine durch Policy erlaubte unveränderliche Fluss- oder Vorlagenrevision. Der Server löst Graph, Quell-Hash, Policy-Entscheidung und geschlossene Ein-/Ausgabeverträge auf und bindet sie; mitgelieferte Graphkopien werden ignoriert, unvereinbare Eingaben und zyklische Referenzen abgelehnt. Abgleichzeilen führen stabile Schlüssel- und Eingabe-Hashes sowie Vorher-/Nachher-Werte. Prüfentscheidungen werden als unveränderliche, mandanteneigene Entscheidungssätze gespeichert; geänderte Eingaben werden ungültig, ohne Fachdaten still umzuschreiben. Ausdrücke führen weder Host- noch Datenbankcode aus. Knoten-Vorschauen sind begrenzt, für die handelnde Person datenschutzgefiltert und werden nicht als Laufergebnis gespeichert. SQL wird in denselben Graphen kompiliert; nicht unterstützte Anweisungen erscheinen als Diagnose. "
"Referenz-Vorschauen behalten die bestehende Grenze von 1.000.000 Byte je serialisiertem Knotenergebnis. "
"LPAD und RPAD weisen Ziellängen über 1.000.000 Zeichen vor dem Reservieren des Auffüllspeichers zurück. "
"Dies gilt auch für übergroße Zwischenergebnisse innerhalb von LENGTH oder SUBSTRING, selbst wenn der endgültige Einzelwert klein wäre. "
"Verringern Sie die gewünschte Auffülllänge; der Ausdruck scheitert mit einer Diagnose am betreffenden Knoten, statt Daten abzuschneiden. "
"Gewöhnliches Unicode-Auffüllen, NULL-Eingaben und Kürzungen innerhalb der Grenze behalten ihr Verhalten. "
"Die abschließende Byteprüfung berücksichtigt weiterhin JSON- und Mehrbyte-Zeichenaufwand. "
"Diese Speicherprüfung ersetzt keine Laufzeit- oder Bytegrenzen für andere Ausdrucksoperationen."
),
},
"dataflow.reference.fields-and-consequences": {
"title": "Dataflow-Felder und Lebenszyklusfolgen",
"summary": "Bedeutung von Geltungsbereich, Revision, Wiederverwendung, Automatisierung, Ausführung, Veröffentlichung, Promotion und Löschung.",
"body": (
"Der Geltungsbereich bestimmt Eigentum und Policy-Vererbung. Vorlagen können abgeleitet, aber nicht ausgeführt werden; vollständige Flüsse dürfen bei wirksamer Policy geprüft, versioniert, automatisiert und ausgeführt werden. Speichern fügt eine unveränderliche Revision an. Eine bereichsbezogene Kopie bindet Quellrevision und Inhalts-Hash; eine neuere Quelle ändert sie nicht automatisch. Die geprüfte Übernahme benötigt den exakten Quell-Hash und eine Begründung, fügt eine Kopierevision an, protokolliert Policy-Entscheidung und prüfende Person und setzt die Kopie zur erneuten Aktivierung auf Entwurf. Trigger binden Revision und Autorisierungsnachweis und prüfen ihre Befugnis je Zustellung neu. „Läufe zulassen“ ist nur die Zulassungsgrenze der Definition und gewährt niemandem eine Berechtigung. Einmalige Läufe verwenden lokale Mandantenzeit; versäumte Intervalle werden je Richtlinie zu einem Lauf zusammengefasst oder übersprungen, niemals ungeprüft vollständig nachgeholt. Das Parallelitätslimit begrenzt aktive Zustellungen und erhöht keine Worker-Kapazität. Läufe erzeugen dauerhafte Befehls- und Recovery-Nachweise. Veröffentlichung erstellt eine gesteuerte Datasource-Materialisierung; Promotion wählt eine unveränderliche Revision für Staging oder Produktion aus. Abgleichentscheidungen werden mit optimistischer Nebenläufigkeit als neue Revision gespeichert und verändern die geprüfte Fachzeile nicht. Löschen verhindert künftige Nutzung, während Lauf-, Bereitstellungs-, Herkunfts-, Audit- und Recovery-Nachweise ihrer Aufbewahrung folgen."
),
},
"dataflow.execution-and-recovery": {
"title": "Dataflow ausführen, veröffentlichen und wiederherstellen",
"summary": "Gebundene Läufe, Umgebungsfreigaben, Ausgabeveröffentlichung, Abbruch, Abgleich und Nachweise betreiben.",
"body": (
"Jeder Lauf ist an eine unveränderliche Revision und einen Idempotenzschlüssel gebunden. Die Warteschlange erfasst handelnde Person, Befugnis, Umgebung, Fortschritt, Abbruch, Ausgabe und Recovery-Zustand. Reine Datenbankläufe werden atomar gespeichert. Die Veröffentlichung in eine gesteuerte Datasource nutzt Vorwärts-Recovery: Ein unbekanntes Anbieterergebnis wird vor einer Wiederholung abgeglichen, damit keine Ausgabe doppelt entsteht. Staging- und Produktionsfreigaben sind ausdrücklich und schreiben keine Revision um. Das Einfrieren einer Veröffentlichung versieht exakt die unveränderliche Ausgabe mit einer dauerhaften Bezeichnung; Daten werden weder kopiert noch von Aufbewahrungs-, Hold- und Zugriffsregeln der Datasource getrennt. Artefaktbasierte und Inline-Ausgaben liefern dieselben stabilen Veröffentlichungs-, Datasource- und Materialisierungsreferenzen. Warnungen und prüfpflichtige Zustände bleiben für Workflow sichtbar. Ein Abbruch ist nach Beginn externer Arbeit nur bestmöglich; der Abschlussnachweis unterscheidet gestoppt, abgeschlossen, fehlgeschlagen und abgleichpflichtig. Vor Annahme eines geplanten, ereignisbasierten oder eingereihten Laufs wird die Modulberechtigung des Mandanten geprüft. Eine Deaktivierung stoppt neue Annahmen und überlässt bereits angenommene Läufe einer ausdrücklichen Betriebsentscheidung. Reporting darf nur einen erfolgreichen veröffentlichten Lauf binden; Dataflow prüft Befugnis und Datasource-Zugriff erneut und liest exakt die protokollierte Materialisierung ohne erneute Parametrisierung oder Ausführung."
),
},
}
def localize_documentation_topics(
topics: Iterable[DocumentationTopic],
) -> tuple[DocumentationTopic, ...]:
localized: list[DocumentationTopic] = []
for topic in topics:
german = _TRANSLATIONS.get(topic.id)
if german is None:
localized.append(topic)
continue
translations = {
locale: dict(value) for locale, value in topic.translations.items()
}
translations["de"] = {**translations.get("de", {}), **german}
localized.append(replace(topic, translations=translations))
return tuple(localized)
@@ -0,0 +1,95 @@
"""German translations for public structured documentation metadata."""
from __future__ import annotations
from typing import Any
GERMAN_STRUCTURED_TRANSLATIONS: dict[str, dict[str, Any]] = {'dataflow.execution-and-recovery': {'consequence_classes': {'cancel_run': 'Stornierung von '
'Anfragen; bereits '
'anerkannte externe '
'Effekte können '
'verbleiben.',
'promote_revision': 'Macht eine '
'unveränderliche '
'Revision in '
'einer höheren '
'Ausführungsumgebung '
'geeignet.',
'publish_output': 'Erstellt oder '
'aktualisiert eine '
'geregelte '
'Datenquelle und '
'fügt eine '
'Materialisierung '
'hinzu.',
'queue_run': 'Erstellt einen '
'dauerhaften asynchronen '
'Befehls- und '
'Autorisierungsnachweis.'}},
'dataflow.reference.fields-and-consequences': {'consequence_classes': {'configure_trigger': 'Erstellt '
'oder '
'ändert '
'einen '
'Automatisierungsbefehl '
'mit '
'Revisions- '
'und '
'Autorisierungsnachweisen.',
'delete_pipeline': 'Verhindert '
'die '
'zukünftige '
'Verwendung, '
'während '
'beibehaltene '
'Nachweise '
'geregelt '
'bleiben.',
'derive_copy': 'Erstellt '
'eine '
'separat '
'verwaltete '
'Kopie, die '
'an die '
'Quellrevision '
'und den '
'Hash '
'gebunden '
'ist.',
'rebase_copy': 'Hängt die '
'genaue '
'überprüfte '
'Quellrevision '
'an eine '
'Scope-Kopie '
'an, '
'zeichnet '
'die '
'Herkunft '
'des '
'Reviewers '
'auf und '
'gibt sie '
'in den '
'Entwurf '
'zurück.',
'record_decision': 'Fügt '
'eine '
'vom '
'handelnde '
'Person '
'zugewiesene '
'unveränderliche '
'Entscheidungsrevision '
'gegen '
'einen '
'genauen '
'Eingabe-Hash '
'an.',
'save_revision': 'Fügt '
'eine '
'unveränderliche '
'Überarbeitung '
'der '
'Pipelinedefinition '
'an.'}}}
+79 -4
View File
@@ -1,5 +1,8 @@
from __future__ import annotations from __future__ import annotations
from govoplan_core.core.modules import with_documentation_structured_translations
from govoplan_dataflow.backend.german_structured_documentation import GERMAN_STRUCTURED_TRANSLATIONS
from pathlib import Path from pathlib import Path
from govoplan_core.core.module_guards import ( from govoplan_core.core.module_guards import (
@@ -18,6 +21,7 @@ from govoplan_core.core.dataflows import (
) )
from govoplan_core.core.modules import ( from govoplan_core.core.modules import (
CapabilityDocumentation, CapabilityDocumentation,
DocumentationCondition,
DocumentationTopic, DocumentationTopic,
FrontendModule, FrontendModule,
FrontendRoute, FrontendRoute,
@@ -52,11 +56,14 @@ from govoplan_dataflow.backend.dsar_provider import (
DATAFLOW_DSAR_CAPABILITY, DATAFLOW_DSAR_CAPABILITY,
DataflowDsarProvider, DataflowDsarProvider,
) )
from govoplan_dataflow.backend.german_documentation import (
localize_documentation_topics,
)
MODULE_ID = "dataflow" MODULE_ID = "dataflow"
MODULE_NAME = "Dataflow" MODULE_NAME = "Dataflow"
MODULE_VERSION = "0.1.20" MODULE_VERSION = "0.1.24"
READ_SCOPE = "dataflow:pipeline:read" READ_SCOPE = "dataflow:pipeline:read"
WRITE_SCOPE = "dataflow:pipeline:write" WRITE_SCOPE = "dataflow:pipeline:write"
@@ -146,7 +153,22 @@ ROLE_TEMPLATES = (
), ),
) )
DOCUMENTATION = ( DOCUMENTATION = localize_documentation_topics((
DocumentationTopic(
id="dataflow.workspace-layout",
title="Dataflow workspace actions",
summary="Find collection-wide commands in their consistent workspace position.",
body="Reload and New pipeline use the persistent full-width workspace header at the upper right; Reload sits immediately before creation. Selecting a record, changing filters, or opening an editor does not move these collection-wide commands into the left pane. SQL editing, validation, previews, saving, and execution keep their existing editor scope and bounded safety rules. Existing permissions, disabled-state rules, and unsaved-change guards still apply. Administrators configure authority through the existing permission system; no new permission or automatic operation is introduced.",
layer="static",
documentation_types=("user", "admin"),
audience=("user", "module_admin", "operator"),
order=5,
translations={"de": {
"title": "Datenflüsse: Aktionen im Arbeitsbereich",
"summary": "Sammlungsweite Aktionen an ihrer einheitlichen Position im Arbeitsbereich finden.",
"body": "Neu laden und Neue Pipeline stehen oben rechts in der dauerhaft sichtbaren, arbeitsbereichsweiten Leiste; Neu laden steht unmittelbar vor dem Anlegen. Auswahl, Filterwechsel und Bearbeitung verschieben diese sammlungsweiten Aktionen nicht in den linken Bereich. SQL-Bearbeitung, Validierung, Vorschau, Speichern und Ausführung behalten ihren bisherigen Editorbereich und ihre begrenzenden Sicherheitsregeln. Bestehende Berechtigungen, Deaktivierungsregeln und der Schutz ungespeicherter Änderungen gelten weiterhin. Administratoren konfigurieren Rechte im bestehenden Berechtigungssystem; es entstehen weder neue Rechte noch automatische Vorgänge.",
}},
),
DocumentationTopic( DocumentationTopic(
id="dataflow.data-subject-requests", id="dataflow.data-subject-requests",
title="Dataflow data-subject requests", title="Dataflow data-subject requests",
@@ -179,7 +201,12 @@ DOCUMENTATION = (
"and credentials; Reporting owns analytical presentation and exports; " "and credentials; Reporting owns analytical presentation and exports; "
"Workflow owns orchestration and human handoffs; Risk Compliance owns sanctions review " "Workflow owns orchestration and human handoffs; Risk Compliance owns sanctions review "
"semantics and policy gates. User SQL is compiled into approved transforms and is never " "semantics and policy gates. User SQL is compiled into approved transforms and is never "
"passed unchecked to a backing database." "passed unchecked to a backing database. Opening or reloading Dataflow does not "
"start a pipeline. If the editor cannot be loaded after a development update, "
"preserve unsaved work before reloading the browser. Administrators should "
"distinguish frontend asset failures from pipeline API or access errors. "
"Development and browser-conformance servers use separate dependency caches; "
"an older server may need restarting after this configuration update."
), ),
layer="available", layer="available",
documentation_types=("admin", "user"), documentation_types=("admin", "user"),
@@ -197,6 +224,19 @@ DOCUMENTATION = (
"audit", "audit",
), ),
metadata={ metadata={
"kind": "workflow",
"route": "/dataflow",
"screen": "Dataflow",
"prerequisites": [
"You may read Dataflow pipeline definitions in the active tenant.",
],
"steps": [
"Open Dataflow and select a pipeline or create an authorized draft.",
"Inspect the graph, immutable revision, diagnostics, and declared source references.",
"Use preview or run actions only when the effective permissions and Policy allow them.",
],
"outcome": "The pipeline remains a governed transformation definition with explicit module boundaries and source authority.",
"verification": "Confirm the active revision, validation diagnostics, source references, and permitted actions in the Dataflow workspace.",
"first_slice": ( "first_slice": (
"Inline and governed datasources, union, join, filter, deduplication, select, " "Inline and governed datasources, union, join, filter, deduplication, select, "
"typed expressions, conversion, quality and reconciliation, reusable subflows, " "typed expressions, conversion, quality and reconciliation, reusable subflows, "
@@ -218,6 +258,27 @@ DOCUMENTATION = (
"dataflow.state.read-only", "dataflow.state.read-only",
], ],
}, },
conditions=(
DocumentationCondition(
required_modules=("dataflow",),
required_scopes=(READ_SCOPE,),
),
),
structured_translation_version="1",
structured_translations={
"de": {
"prerequisites": [
"Sie dürfen Dataflow-Pipeline-Definitionen im aktiven Mandanten lesen.",
],
"steps": [
"Öffnen Sie Dataflow und wählen Sie eine Pipeline oder legen Sie einen autorisierten Entwurf an.",
"Prüfen Sie Graph, unveränderliche Revision, Diagnosen und ausgewiesene Quellreferenzen.",
"Verwenden Sie Vorschau- oder Laufaktionen nur, wenn wirksame Berechtigungen und Policy sie erlauben.",
],
"outcome": "Die Pipeline bleibt eine gesteuerte Transformationsdefinition mit ausdrücklichen Modulgrenzen und Quellenautorität.",
"verification": "Prüfen Sie aktive Revision, Validierungsdiagnosen, Quellreferenzen und erlaubte Aktionen im Dataflow-Arbeitsbereich.",
}
},
), ),
DocumentationTopic( DocumentationTopic(
id="dataflow.reference.nodes-and-expressions", id="dataflow.reference.nodes-and-expressions",
@@ -240,6 +301,13 @@ DOCUMENTATION = (
"code. Selecting a node may request a bounded intermediate preview; preview rows are transient, " "code. Selecting a node may request a bounded intermediate preview; preview rows are transient, "
"privacy-filtered for the actor, and are not retained as run output. SQL editing compiles into the same " "privacy-filtered for the actor, and are not retained as run output. SQL editing compiles into the same "
"canonical graph, so unsupported statements are diagnostics rather than pass-through SQL. " "canonical graph, so unsupported statements are diagnostics rather than pass-through SQL. "
"Reference previews retain the existing 1,000,000-byte serialized result limit per node. "
"LPAD and RPAD reject target lengths above 1,000,000 characters before allocating padding, "
"including oversized intermediate values inside LENGTH or SUBSTRING even if the final scalar would be small. "
"Reduce the requested padding length; this fails the expression at its node instead of truncating data. "
"Ordinary Unicode padding, null inputs, and in-budget truncation keep their existing behavior; "
"the final byte check still accounts for JSON and multibyte character overhead. "
"This allocation guard does not replace runtime or byte limits for other expression operations."
), ),
layer="available", layer="available",
documentation_types=("admin", "user"), documentation_types=("admin", "user"),
@@ -247,6 +315,7 @@ DOCUMENTATION = (
order=76, order=76,
related_modules=("datasources", "connectors", "policy", "audit"), related_modules=("datasources", "connectors", "policy", "audit"),
metadata={ metadata={
"kind": "reference",
"help_contexts": [ "help_contexts": [
"dataflow.field.node-name", "dataflow.field.node-name",
"dataflow.field.source", "dataflow.field.source",
@@ -296,6 +365,7 @@ DOCUMENTATION = (
"audit", "audit",
), ),
metadata={ metadata={
"kind": "reference",
"help_contexts": [ "help_contexts": [
"dataflow.field.scope", "dataflow.field.scope",
"dataflow.field.definition-kind", "dataflow.field.definition-kind",
@@ -363,7 +433,7 @@ DOCUMENTATION = (
}, },
}, },
), ),
) ))
def _dataflow_router(context: ModuleContext): def _dataflow_router(context: ModuleContext):
@@ -759,6 +829,11 @@ manifest = ModuleManifest(
) )
manifest = with_documentation_structured_translations(
manifest, locale="de", translations=GERMAN_STRUCTURED_TRANSLATIONS
)
def get_manifest() -> ModuleManifest: def get_manifest() -> ModuleManifest:
return manifest return manifest
@@ -0,0 +1,3 @@
"""Shared existing limits for reference-preview results and allocating expressions."""
MAX_RESULT_BYTES = 1_000_000
+19
View File
@@ -0,0 +1,19 @@
from govoplan_dataflow.backend.manifest import get_manifest
def test_static_documentation_has_complete_german_reference_copy() -> None:
for topic in get_manifest().documentation:
german = topic.translations.get("de", {})
assert all(german.get(field, "").strip() for field in ("title", "summary", "body")), topic.id
def test_documentation_exposes_conditioned_workflow_and_reference() -> None:
topics = {topic.id: topic for topic in get_manifest().documentation}
workflow = topics["dataflow.module-boundary"]
assert workflow.metadata.get("kind") == "workflow"
assert any(condition.required_scopes for condition in workflow.conditions)
assert workflow.structured_translations.get("de")
reference = topics["dataflow.reference.fields-and-consequences"]
assert reference.metadata.get("kind") == "reference"
assert reference.metadata.get("consequence_classes")
+165
View File
@@ -0,0 +1,165 @@
from __future__ import annotations
import unittest
from govoplan_dataflow.backend.executor import (
MAX_RESULT_BYTES,
PipelineExecutionError,
execute_preview,
)
from govoplan_dataflow.backend.expressions import ExpressionError, evaluate_expression
from govoplan_dataflow.backend.manifest import get_manifest
from govoplan_dataflow.backend.schemas import (
GraphEdge,
GraphNode,
GraphPosition,
PipelineGraph,
)
class _UnreadableFill:
def __str__(self) -> str:
raise AssertionError(
"Oversized padding must be rejected before reading or multiplying its fill."
)
def _expression_graph(expression: str, *, result_type: str = "string") -> PipelineGraph:
return PipelineGraph(
nodes=[
GraphNode(
id="source",
type="source.inline",
label="Source",
position=GraphPosition(x=0, y=0),
config={"source_name": "fixture", "rows": [{"value": "x"}]},
),
GraphNode(
id="padding",
type="expression",
label="Padding",
position=GraphPosition(x=200, y=0),
config={
"target_column": "padded",
"expression": expression,
"result_type": result_type,
},
),
GraphNode(
id="output",
type="output",
label="Output",
position=GraphPosition(x=400, y=0),
config={},
),
],
edges=[
GraphEdge(id="source-padding", source="source", target="padding"),
GraphEdge(id="padding-output", source="padding", target="output"),
],
)
class PaddingBudgetTests(unittest.TestCase):
def test_oversized_padding_is_rejected_before_fill_evaluation_or_allocation(
self,
) -> None:
for operation in ("lpad", "rpad"):
for length in (MAX_RESULT_BYTES + 1, 10**100):
with self.subTest(operation=operation, length=length):
with self.assertRaisesRegex(
ExpressionError, "Padding length.*1,000,000"
):
evaluate_expression(
f"{operation}('x', {length}, fill)",
{"fill": _UnreadableFill()},
)
def test_budget_cannot_be_bypassed_by_hiding_large_padding_in_a_small_scalar(
self,
) -> None:
for wrapper in ("length({})", "substring({}, 1, 1)"):
with self.subTest(wrapper=wrapper):
expression = wrapper.format(f"lpad('x', {MAX_RESULT_BYTES + 1}, fill)")
with self.assertRaises(ExpressionError):
evaluate_expression(expression, {"fill": _UnreadableFill()})
def test_existing_boundary_and_ordinary_padding_are_preserved(self) -> None:
self.assertEqual(1_000_000, MAX_RESULT_BYTES)
for operation in ("lpad", "rpad"):
with self.subTest(operation=operation):
value = evaluate_expression(
f"{operation}('x', {MAX_RESULT_BYTES}, '0')", {}
)
self.assertEqual(MAX_RESULT_BYTES, len(value))
self.assertEqual(1, value.count("x"))
self.assertEqual(
"abc", evaluate_expression(f"{operation}('abcdef', 3, '')", {})
)
self.assertEqual(
"", evaluate_expression(f"{operation}('abcdef', 0, '')", {})
)
self.assertEqual(
"abc", evaluate_expression(f"{operation}('abc', 3, '')", {})
)
def test_null_negative_and_empty_fill_semantics_are_unchanged(self) -> None:
for operation in ("lpad", "rpad"):
with self.subTest(operation=operation):
self.assertIsNone(
evaluate_expression(f"{operation}(NULL, {10**100}, '')", {})
)
self.assertIsNone(evaluate_expression(f"{operation}(NULL, -1, '')", {}))
with self.assertRaisesRegex(ValueError, "cannot be negative"):
evaluate_expression(f"{operation}('x', -1, '0')", {})
with self.assertRaisesRegex(ValueError, "fill text cannot be empty"):
evaluate_expression(f"{operation}('x', 2, '')", {})
def test_multibyte_fill_and_truncation_preserve_character_semantics(self) -> None:
self.assertEqual("ö🙂öÄ", evaluate_expression("lpad('Ä', 4, 'ö🙂')", {}))
self.assertEqual("Äö🙂ö", evaluate_expression("rpad('Ä', 4, 'ö🙂')", {}))
self.assertEqual("🙂ä", evaluate_expression("lpad('🙂ä中', 2, '0')", {}))
result = execute_preview(
_expression_graph("rpad(value, 4, 'ö🙂')"), row_limit=10
)
self.assertEqual("xö🙂ö", result.rows[0]["padded"])
def test_preview_reports_padding_guard_at_owning_node_and_retains_final_byte_limit(
self,
) -> None:
with self.assertRaisesRegex(PipelineExecutionError, "Padding length") as raised:
execute_preview(
_expression_graph(
f"length(lpad(value, {MAX_RESULT_BYTES + 1}, '0'))",
result_type="integer",
),
row_limit=10,
)
self.assertEqual("padding", raised.exception.node_id)
# Non-ASCII characters need several serialized bytes. The preallocation
# character bound supplements, and never replaces, the node byte bound.
with self.assertRaisesRegex(
PipelineExecutionError, "one-megabyte result limit"
) as raised:
execute_preview(_expression_graph("rpad(value, 200000, 'ö')"), row_limit=10)
self.assertEqual("padding", raised.exception.node_id)
def test_user_and_operator_documentation_explains_intermediate_padding_limit_in_both_languages(
self,
) -> None:
topic = next(
topic
for topic in get_manifest().documentation
if topic.id == "dataflow.reference.nodes-and-expressions"
)
self.assertIn("user", topic.documentation_types)
self.assertIn("admin", topic.documentation_types)
for text in (topic.body, topic.translations["de"]["body"]):
self.assertIn("LPAD", text)
self.assertIn("RPAD", text)
self.assertIn("LENGTH", text)
self.assertIn("SUBSTRING", text)
if __name__ == "__main__":
unittest.main()
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/dataflow-webui", "name": "@govoplan/dataflow-webui",
"version": "0.1.20", "version": "0.1.24",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
@@ -18,7 +18,7 @@
"test:structure": "node scripts/test-dataflow-page-structure.mjs" "test:structure": "node scripts/test-dataflow-page-structure.mjs"
}, },
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.18", "@govoplan/core-webui": "^0.1.45",
"@xyflow/react": "^12.11.2", "@xyflow/react": "^12.11.2",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": ">=19.2.7 <20", "react": ">=19.2.7 <20",
+15 -11
View File
@@ -615,18 +615,8 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings
return ( return (
<WorkspaceFrame as="main" height="viewport" surface="plain" className="dataflow-page" label="Dataflow workspace"> <WorkspaceFrame as="main" height="viewport" surface="plain" className="dataflow-page" label="Dataflow workspace">
<WorkspaceLayout
variant="split"
primarySize="compact"
surface="contained"
primaryScrollable={false}
contentScrollable={false}
primaryLabel="Data pipelines"
contentLabel="Pipeline editor"
contentClassName="dataflow-workspace"
primary={<>
<WorkspaceActionBar <WorkspaceActionBar
scope="collection-pane" scope="workspace"
variant="collection" variant="collection"
refreshable refreshable
reloadAction={{ onReload: () => void loadPipelines(draft?.id), loading, label: "Refresh pipelines" }} reloadAction={{ onReload: () => void loadPipelines(draft?.id), loading, label: "Refresh pipelines" }}
@@ -640,6 +630,16 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings
disabledReason={!canWrite ? DATAFLOW_I18N.writeReason : undefined} disabledReason={!canWrite ? DATAFLOW_I18N.writeReason : undefined}
/>} />}
/> />
<WorkspaceLayout
variant="split"
primarySize="compact"
surface="contained"
primaryScrollable={false}
contentScrollable={false}
primaryLabel="Data pipelines"
contentLabel="Pipeline editor"
contentClassName="dataflow-workspace"
primary={<>
<FilterBar surface="panel"> <FilterBar surface="panel">
<input <input
type="search" type="search"
@@ -816,6 +816,8 @@ export default function DataflowPage({ settings, auth }: { settings: ApiSettings
destructiveActions={draft.id ? ( destructiveActions={draft.id ? (
<IconButton <IconButton
label="Delete pipeline" label="Delete pipeline"
helpContextId="dataflow.action.delete"
helpModuleId="dataflow"
icon={<Trash2 size={16} />} icon={<Trash2 size={16} />}
variant="danger" variant="danger"
onClick={() => setDeleteOpen(true)} onClick={() => setDeleteOpen(true)}
@@ -2039,6 +2041,8 @@ function DataflowTriggersDialog({
{selected.last_error ? <small className="is-error">{selected.last_error}</small> : null} {selected.last_error ? <small className="is-error">{selected.last_error}</small> : null}
<Button <Button
variant="danger" variant="danger"
helpContextId="dataflow.action.delete"
helpModuleId="dataflow"
onClick={() => requestNavigation(() => setDeleteCandidate(selected))} onClick={() => requestNavigation(() => setDeleteCandidate(selected))}
disabled={busy || !editable} disabled={busy || !editable}
disabledReason={busy ? DATAFLOW_I18N.working : !editable ? DATAFLOW_I18N.writeReason : undefined} disabledReason={busy ? DATAFLOW_I18N.working : !editable ? DATAFLOW_I18N.writeReason : undefined}
@@ -182,6 +182,8 @@ export default function NodeInspector({
/> />
<IconButton <IconButton
label="Delete node" label="Delete node"
helpContextId="dataflow.action.delete"
helpModuleId="dataflow"
icon={<Trash2 size={16} />} icon={<Trash2 size={16} />}
variant="danger" variant="danger"
onClick={() => onDelete(node.id)} onClick={() => onDelete(node.id)}