This is an automated email from the ASF dual-hosted git repository. msyavuz pushed a commit to branch msyavuz/feat/canvas in repository https://gitbox.apache.org/repos/asf/superset.git
commit bf7d0fe3f608e2d3ca78713b49442fffcfda6f3d Author: Mehmet Salih Yavuz <[email protected]> AuthorDate: Thu Oct 1 15:42:17 2026 +0300 feat(canvas): drop the arbitrary definition limits The caps on node count, nesting depth, operations per request, widget id length and the upper bounds on grid and settings values were never calibrated against real dashboards, so they're removed. Only structural minimums remain. Reading order is computed iteratively so deep trees can't exhaust the recursion limit. --- superset/canvas/definition/schemas.py | 20 +++++++++----------- superset/canvas/definition/scopes.py | 13 +++++-------- superset/canvas/definition/validation.py | 17 ----------------- tests/unit_tests/canvas/validation_test.py | 13 +++++++++++++ 4 files changed, 27 insertions(+), 36 deletions(-) diff --git a/superset/canvas/definition/schemas.py b/superset/canvas/definition/schemas.py index 1bc55ba31ba..0522f2a865d 100644 --- a/superset/canvas/definition/schemas.py +++ b/superset/canvas/definition/schemas.py @@ -46,9 +46,9 @@ class _Model(BaseModel): class RootLayout(_Model): - columns: int = Field(default=24, ge=1, le=48) - gap: int = Field(default=16, ge=0, le=64) - row_unit: int = Field(default=40, ge=8, le=400) + columns: int = Field(default=24, ge=1) + gap: int = Field(default=16, ge=0) + row_unit: int = Field(default=40, ge=1) class Root(_Model): @@ -58,7 +58,7 @@ class Root(_Model): class Node(_Model): # Id of the placed widget. - widget: str = Field(min_length=1, max_length=64) + widget: str = Field(min_length=1) # Placement within the parent, validated against the parent's rules. layout: dict[str, Any] = Field(default_factory=dict) # Present only when the widget is a container. @@ -112,10 +112,10 @@ class Interactions(_Model): class RefreshSettings(_Model): # Seconds between automatic refreshes of every widget's data; 0 is off. - interval: int = Field(default=0, ge=0, le=7 * 24 * 60 * 60) + interval: int = Field(default=0, ge=0) # Milliseconds a refresh is spread over across widgets; 0 refreshes all at # once. - stagger: int = Field(default=0, ge=0, le=10 * 60 * 1000) + stagger: int = Field(default=0, ge=0) # Nodes left out of automatic refresh. exempt: list[str] = Field(default_factory=list) @@ -124,9 +124,7 @@ class ColorSettings(_Model): # Categorical color scheme shared by every widget; the default when unset. scheme: str | None = Field(default=None, min_length=1, max_length=255) # Fixed colors for series labels across widgets, e.g. {"France": "#1f77b4"}. - label_colors: dict[str, Annotated[str, Field(max_length=64)]] = Field( - default_factory=dict, max_length=1000 - ) + label_colors: dict[str, str] = Field(default_factory=dict) class DisplaySettings(_Model): @@ -171,7 +169,7 @@ class AddOp(_Model): op: Literal["add"] id: str | None = Field(default=None, pattern=NODE_ID_PATTERN) - widget: str = Field(min_length=1, max_length=64) + widget: str = Field(min_length=1) layout: dict[str, Any] = Field(default_factory=dict) parent: str = ROOT_ID # Position in the parent's children (reading order); appended when omitted. @@ -235,4 +233,4 @@ class ApplyOperationsRequest(BaseModel): # The revision the caller's view of the canvas is based on. base_revision: int = Field(ge=0) - ops: list[Operation] = Field(min_length=1, max_length=200) + ops: list[Operation] = Field(min_length=1) diff --git a/superset/canvas/definition/scopes.py b/superset/canvas/definition/scopes.py index 2fe94c996de..1ad677451f4 100644 --- a/superset/canvas/definition/scopes.py +++ b/superset/canvas/definition/scopes.py @@ -62,15 +62,12 @@ SCOPE_RESULT_KEYS = { def _reading_order(canvas: dict[str, Any], start: str) -> list[str]: """Node ids under ``start`` (exclusive), depth first in children order.""" - children = ( - canvas["root"]["children"] - if start == ROOT_ID - else canvas["nodes"][start].get("children") or [] - ) ordered: list[str] = [] - for child in children: - ordered.append(child) - ordered.extend(_reading_order(canvas, child)) + stack = list(reversed(_children(canvas, start))) + while stack: + node_id = stack.pop() + ordered.append(node_id) + stack.extend(reversed(_children(canvas, node_id))) return ordered diff --git a/superset/canvas/definition/validation.py b/superset/canvas/definition/validation.py index 03ec67dda41..d04a5440e71 100644 --- a/superset/canvas/definition/validation.py +++ b/superset/canvas/definition/validation.py @@ -53,9 +53,6 @@ from superset.canvas.definition.schemas import ( SCOPE_FIELDS, ) -MAX_NODES = 500 -MAX_DEPTH = 8 - # The layout rule a node needs to own each kind of scope override. SCOPE_ROLES = { "filter": ("is_filter", "a filter"), @@ -123,12 +120,9 @@ class _Validator: raise DefinitionValidationError(_pydantic_issues((), ex)) from ex doc = canvas.model_dump(mode="json", by_alias=True, exclude_none=True) - if len(doc["nodes"]) > MAX_NODES: - self.fail("/nodes", f"more than {MAX_NODES} nodes") self._check_tree(doc) self._check_widgets(doc) self._check_nesting() - self._check_depth(doc) self._check_interactions(doc) self._check_settings(doc) if not self.issues: @@ -262,17 +256,6 @@ class _Validator: f"unknown node {node_id!r}", ) - def _check_depth(self, doc: dict[str, Any]) -> None: - for node_id in doc["nodes"]: - depth, current = 1, node_id - while self.parents.get(current, ROOT_ID) != ROOT_ID and depth <= MAX_DEPTH: - current = self.parents[current] - depth += 1 - if depth > MAX_DEPTH: - self.fail( - pointer("nodes", node_id), f"nested deeper than {MAX_DEPTH} levels" - ) - def _resolve_layouts(self, doc: dict[str, Any]) -> None: self._resolve_children(doc, ROOT_ID, doc["root"]["layout"]["columns"], None) for node_id, rules in self.node_rules.items(): diff --git a/tests/unit_tests/canvas/validation_test.py b/tests/unit_tests/canvas/validation_test.py index a9ec1e1035f..681b0e5ef60 100644 --- a/tests/unit_tests/canvas/validation_test.py +++ b/tests/unit_tests/canvas/validation_test.py @@ -244,3 +244,16 @@ def test_settings_are_validated() -> None: "/settings/refresh/interval", "/settings/colors/theme", } + + +def test_nesting_depth_is_not_capped() -> None: + nodes = { + f"g{level}": node( + f"group-{level}", children=[f"g{level + 1}"] if level < 29 else [] + ) + for level in range(30) + } + + result = normalize(canvas(nodes, children=["g0"])) + + assert len(result["nodes"]) == 30
