From a229a24c8ecac28704e14ab3d5308a905654820e Mon Sep 17 00:00:00 2001 From: "fangshiyuan.fsy@alibaba-inc.com" Date: Fri, 29 May 2026 16:29:00 +0800 Subject: [PATCH 1/3] fix(variables): accept JSON string and empty input for json_schema VariableEntity.json_schema previously only accepted dict/None, but the frontend code editor persists the schema as raw JSON text, and clearing the schema sends an empty string instead of dropping the field. Both cases triggered validation errors. This change switches validate_json_schema to mode="before" so it can: parse JSON strings, treat None/"" as "no schema", and reject non-object inputs with a clear message. Adds tests covering dict / JSON string / empty / invalid JSON / non-object / semantically invalid schema cases. Refs #164 --- src/graphon/variables/input_entities.py | 18 +++++- tests/variables/__init__.py | 0 tests/variables/test_input_entities.py | 75 +++++++++++++++++++++++++ 3 files changed, 90 insertions(+), 3 deletions(-) create mode 100644 tests/variables/__init__.py create mode 100644 tests/variables/test_input_entities.py diff --git a/src/graphon/variables/input_entities.py b/src/graphon/variables/input_entities.py index 3638306..0fef07b 100644 --- a/src/graphon/variables/input_entities.py +++ b/src/graphon/variables/input_entities.py @@ -1,3 +1,4 @@ +import json from collections.abc import Sequence from enum import StrEnum from typing import Any @@ -53,14 +54,25 @@ def convert_none_description(cls, value: Any) -> str: def convert_none_options(cls, value: Any) -> Sequence[str]: return value or [] - @field_validator("json_schema") + @field_validator("json_schema", mode="before") @classmethod def validate_json_schema( cls, - schema: dict[str, Any] | None, + schema: str | dict[str, Any] | None, ) -> dict[str, Any] | None: - if schema is None: + # The schema is persisted as raw editor text on the frontend, so accept + # either a parsed object, a JSON string, or empty/None inputs. + if schema is None or schema == "": return None + if isinstance(schema, str): + try: + schema = json.loads(schema) + except json.JSONDecodeError as error: + msg = f"json_schema is not valid JSON: {error.msg}" + raise ValueError(msg) from error + if not isinstance(schema, dict): + msg = f"json_schema must be a JSON object, got {type(schema).__name__}" + raise ValueError(msg) try: Draft7Validator.check_schema(schema) except SchemaError as error: diff --git a/tests/variables/__init__.py b/tests/variables/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/variables/test_input_entities.py b/tests/variables/test_input_entities.py new file mode 100644 index 0000000..c7f789e --- /dev/null +++ b/tests/variables/test_input_entities.py @@ -0,0 +1,75 @@ +import pytest +from pydantic import ValidationError + +from graphon.variables.input_entities import VariableEntity, VariableEntityType + +_VALID_SCHEMA: dict = { + "type": "object", + "properties": { + "name": {"type": "string"}, + "age": {"type": "integer", "minimum": 0}, + }, + "required": ["name"], +} + + +def _make_payload(json_schema): + return { + "variable": "profile", + "label": "profile", + "type": VariableEntityType.JSON_OBJECT, + "json_schema": json_schema, + } + + +class TestValidateJsonSchema: + def test_accepts_dict_input(self): + entity = VariableEntity.model_validate(_make_payload(_VALID_SCHEMA)) + assert entity.json_schema == _VALID_SCHEMA + + def test_accepts_json_string_input(self): + # The frontend's code editor persists the schema as a raw JSON string. + raw = ( + "{\n" + ' "type": "object",\n' + ' "properties": {\n' + ' "name": {"type": "string"},\n' + ' "age": {"type": "integer", "minimum": 0}\n' + " },\n" + ' "required": ["name"]\n' + "}" + ) + entity = VariableEntity.model_validate(_make_payload(raw)) + assert entity.json_schema == _VALID_SCHEMA + + def test_treats_none_as_none(self): + entity = VariableEntity.model_validate(_make_payload(None)) + assert entity.json_schema is None + + def test_treats_empty_string_as_none(self): + # Frontend "clear schema" sends "" rather than removing the field; + # treating it as None matches the user's intent of "no constraint". + entity = VariableEntity.model_validate(_make_payload("")) + assert entity.json_schema is None + + def test_rejects_malformed_json_string(self): + with pytest.raises(ValidationError) as exc_info: + VariableEntity.model_validate(_make_payload('{"type": "object"')) + assert "not valid JSON" in str(exc_info.value) + + def test_rejects_non_object_non_string_input(self): + with pytest.raises(ValidationError) as exc_info: + VariableEntity.model_validate(_make_payload(123)) + assert "must be a JSON object" in str(exc_info.value) + + def test_rejects_semantically_invalid_schema(self): + bad_schema = {"type": "not_a_real_type"} + with pytest.raises(ValidationError) as exc_info: + VariableEntity.model_validate(_make_payload(bad_schema)) + assert "Invalid JSON schema" in str(exc_info.value) + + def test_rejects_semantically_invalid_schema_from_string(self): + bad_schema_str = '{"type": "not_a_real_type"}' + with pytest.raises(ValidationError) as exc_info: + VariableEntity.model_validate(_make_payload(bad_schema_str)) + assert "Invalid JSON schema" in str(exc_info.value) From eba4abc35e0fc500f7384928cd7559b0e074a7c0 Mon Sep 17 00:00:00 2001 From: "fangshiyuan.fsy@alibaba-inc.com" Date: Mon, 1 Jun 2026 10:08:37 +0800 Subject: [PATCH 2/3] type-check fix --- src/graphon/variables/input_entities.py | 8 ++++++-- tests/variables/test_input_entities.py | 20 +++++++++++--------- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/src/graphon/variables/input_entities.py b/src/graphon/variables/input_entities.py index 0fef07b..285ca6b 100644 --- a/src/graphon/variables/input_entities.py +++ b/src/graphon/variables/input_entities.py @@ -62,17 +62,21 @@ def validate_json_schema( ) -> dict[str, Any] | None: # The schema is persisted as raw editor text on the frontend, so accept # either a parsed object, a JSON string, or empty/None inputs. - if schema is None or schema == "": + if schema is None: return None if isinstance(schema, str): + if not schema: + return None try: schema = json.loads(schema) except json.JSONDecodeError as error: msg = f"json_schema is not valid JSON: {error.msg}" raise ValueError(msg) from error if not isinstance(schema, dict): + # Pydantic only wraps ValueError/AssertionError into ValidationError, + # so we deliberately keep ValueError instead of TypeError here. msg = f"json_schema must be a JSON object, got {type(schema).__name__}" - raise ValueError(msg) + raise ValueError(msg) # noqa: TRY004 try: Draft7Validator.check_schema(schema) except SchemaError as error: diff --git a/tests/variables/test_input_entities.py b/tests/variables/test_input_entities.py index c7f789e..2a7e9f6 100644 --- a/tests/variables/test_input_entities.py +++ b/tests/variables/test_input_entities.py @@ -1,3 +1,5 @@ +from typing import Any + import pytest from pydantic import ValidationError @@ -13,7 +15,7 @@ } -def _make_payload(json_schema): +def _make_payload(json_schema: Any) -> dict[str, Any]: return { "variable": "profile", "label": "profile", @@ -23,11 +25,11 @@ def _make_payload(json_schema): class TestValidateJsonSchema: - def test_accepts_dict_input(self): + def test_accepts_dict_input(self) -> None: entity = VariableEntity.model_validate(_make_payload(_VALID_SCHEMA)) assert entity.json_schema == _VALID_SCHEMA - def test_accepts_json_string_input(self): + def test_accepts_json_string_input(self) -> None: # The frontend's code editor persists the schema as a raw JSON string. raw = ( "{\n" @@ -42,33 +44,33 @@ def test_accepts_json_string_input(self): entity = VariableEntity.model_validate(_make_payload(raw)) assert entity.json_schema == _VALID_SCHEMA - def test_treats_none_as_none(self): + def test_treats_none_as_none(self) -> None: entity = VariableEntity.model_validate(_make_payload(None)) assert entity.json_schema is None - def test_treats_empty_string_as_none(self): + def test_treats_empty_string_as_none(self) -> None: # Frontend "clear schema" sends "" rather than removing the field; # treating it as None matches the user's intent of "no constraint". entity = VariableEntity.model_validate(_make_payload("")) assert entity.json_schema is None - def test_rejects_malformed_json_string(self): + def test_rejects_malformed_json_string(self) -> None: with pytest.raises(ValidationError) as exc_info: VariableEntity.model_validate(_make_payload('{"type": "object"')) assert "not valid JSON" in str(exc_info.value) - def test_rejects_non_object_non_string_input(self): + def test_rejects_non_object_non_string_input(self) -> None: with pytest.raises(ValidationError) as exc_info: VariableEntity.model_validate(_make_payload(123)) assert "must be a JSON object" in str(exc_info.value) - def test_rejects_semantically_invalid_schema(self): + def test_rejects_semantically_invalid_schema(self) -> None: bad_schema = {"type": "not_a_real_type"} with pytest.raises(ValidationError) as exc_info: VariableEntity.model_validate(_make_payload(bad_schema)) assert "Invalid JSON schema" in str(exc_info.value) - def test_rejects_semantically_invalid_schema_from_string(self): + def test_rejects_semantically_invalid_schema_from_string(self) -> None: bad_schema_str = '{"type": "not_a_real_type"}' with pytest.raises(ValidationError) as exc_info: VariableEntity.model_validate(_make_payload(bad_schema_str)) From bb619159a6a58a02e6cd31426efa7ff42c40b66a Mon Sep 17 00:00:00 2001 From: WH-2099 Date: Tue, 2 Jun 2026 19:09:35 +0800 Subject: [PATCH 3/3] fix(variables): tighten json_schema string parsing --- src/graphon/variables/input_entities.py | 11 ++++++++++- tests/variables/test_input_entities.py | 12 ++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/graphon/variables/input_entities.py b/src/graphon/variables/input_entities.py index 285ca6b..b900d57 100644 --- a/src/graphon/variables/input_entities.py +++ b/src/graphon/variables/input_entities.py @@ -12,6 +12,11 @@ ) +def _reject_non_standard_json_constant(constant: str) -> None: + msg = f"json_schema is not valid JSON: invalid constant {constant}" + raise ValueError(msg) + + class VariableEntityType(StrEnum): TEXT_INPUT = "text-input" SELECT = "select" @@ -65,10 +70,14 @@ def validate_json_schema( if schema is None: return None if isinstance(schema, str): + schema = schema.strip() if not schema: return None try: - schema = json.loads(schema) + schema = json.loads( + schema, + parse_constant=_reject_non_standard_json_constant, + ) except json.JSONDecodeError as error: msg = f"json_schema is not valid JSON: {error.msg}" raise ValueError(msg) from error diff --git a/tests/variables/test_input_entities.py b/tests/variables/test_input_entities.py index 2a7e9f6..4d96659 100644 --- a/tests/variables/test_input_entities.py +++ b/tests/variables/test_input_entities.py @@ -54,11 +54,23 @@ def test_treats_empty_string_as_none(self) -> None: entity = VariableEntity.model_validate(_make_payload("")) assert entity.json_schema is None + def test_treats_whitespace_string_as_none(self) -> None: + entity = VariableEntity.model_validate(_make_payload(" \n\t ")) + assert entity.json_schema is None + def test_rejects_malformed_json_string(self) -> None: with pytest.raises(ValidationError) as exc_info: VariableEntity.model_validate(_make_payload('{"type": "object"')) assert "not valid JSON" in str(exc_info.value) + @pytest.mark.parametrize("constant", ["NaN", "Infinity", "-Infinity"]) + def test_rejects_non_standard_json_constant(self, constant: str) -> None: + with pytest.raises(ValidationError) as exc_info: + VariableEntity.model_validate( + _make_payload(f'{{"type": "number", "minimum": {constant}}}'), + ) + assert "invalid constant" in str(exc_info.value) + def test_rejects_non_object_non_string_input(self) -> None: with pytest.raises(ValidationError) as exc_info: VariableEntity.model_validate(_make_payload(123))