fix: reject scalar context foreach sources
This commit is contained in:
@@ -82,6 +82,33 @@ def _run_with(*frames: ExecutionFrame) -> RunState:
|
||||
return run
|
||||
|
||||
|
||||
def _nested_foreach_workflow() -> Workflow:
|
||||
return _workflow(
|
||||
start="outer",
|
||||
nodes=[
|
||||
_foreach("outer", alias="outer_item"),
|
||||
_foreach("inner", alias="inner_item", over="state.inner_items"),
|
||||
_node("inner_body"),
|
||||
_node("after_inner"),
|
||||
],
|
||||
edges=[
|
||||
{"from": "outer", "outcome": "loop", "to": "inner"},
|
||||
{"from": "inner", "outcome": "loop", "to": "inner_body"},
|
||||
{"from": "inner", "outcome": "done", "to": "after_inner"},
|
||||
{"from": "inner_body", "outcome": "ok", "to": "inner"},
|
||||
{"from": "after_inner", "outcome": "ok", "to": "outer"},
|
||||
{"from": "outer", "outcome": "done", "to": END},
|
||||
],
|
||||
state_schema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"items": {"type": "array", "items": {"type": "string"}},
|
||||
"inner_items": {"type": "array", "items": {"type": "integer"}},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_frame_context_view_uses_standard_and_foreach_contract_keys() -> None:
|
||||
root = ExecutionFrame(
|
||||
id="root",
|
||||
@@ -280,30 +307,7 @@ def test_region_conflicted_node_receives_no_guaranteed_foreach_fields() -> None:
|
||||
|
||||
|
||||
def test_nested_foreach_replaces_inner_scope_and_restores_outer_scope() -> None:
|
||||
workflow = _workflow(
|
||||
start="outer",
|
||||
nodes=[
|
||||
_foreach("outer", alias="outer_item"),
|
||||
_foreach("inner", alias="inner_item", over="state.inner_items"),
|
||||
_node("inner_body"),
|
||||
_node("after_inner"),
|
||||
],
|
||||
edges=[
|
||||
{"from": "outer", "outcome": "loop", "to": "inner"},
|
||||
{"from": "inner", "outcome": "loop", "to": "inner_body"},
|
||||
{"from": "inner", "outcome": "done", "to": "after_inner"},
|
||||
{"from": "inner_body", "outcome": "ok", "to": "inner"},
|
||||
{"from": "after_inner", "outcome": "ok", "to": "outer"},
|
||||
{"from": "outer", "outcome": "done", "to": END},
|
||||
],
|
||||
state_schema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"items": {"type": "array", "items": {"type": "string"}},
|
||||
"inner_items": {"type": "array", "items": {"type": "integer"}},
|
||||
},
|
||||
},
|
||||
)
|
||||
workflow = _nested_foreach_workflow()
|
||||
|
||||
inner = _field_map(workflow, "inner_body")
|
||||
after_inner = _field_map(workflow, "after_inner")
|
||||
@@ -326,30 +330,7 @@ def test_nested_foreach_replaces_inner_scope_and_restores_outer_scope() -> None:
|
||||
|
||||
|
||||
def test_inner_completion_schema_restores_outer_structured_entry() -> None:
|
||||
workflow = _workflow(
|
||||
start="outer",
|
||||
nodes=[
|
||||
_foreach("outer", alias="outer_item"),
|
||||
_foreach("inner", alias="inner_item", over="state.inner_items"),
|
||||
_node("inner_body"),
|
||||
_node("after_inner"),
|
||||
],
|
||||
edges=[
|
||||
{"from": "outer", "outcome": "loop", "to": "inner"},
|
||||
{"from": "inner", "outcome": "loop", "to": "inner_body"},
|
||||
{"from": "inner", "outcome": "done", "to": "after_inner"},
|
||||
{"from": "inner_body", "outcome": "ok", "to": "inner"},
|
||||
{"from": "after_inner", "outcome": "ok", "to": "outer"},
|
||||
{"from": "outer", "outcome": "done", "to": END},
|
||||
],
|
||||
state_schema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"items": {"type": "array", "items": {"type": "string"}},
|
||||
"inner_items": {"type": "array", "items": {"type": "integer"}},
|
||||
},
|
||||
},
|
||||
)
|
||||
workflow = _nested_foreach_workflow()
|
||||
after_inner = _field_map(workflow, "after_inner")
|
||||
foreach_schema = after_inner["foreach"].schema
|
||||
assert set(foreach_schema["properties"]) == {"outer"}
|
||||
@@ -646,16 +627,17 @@ def test_ref_sibling_constraints_remain_conjunctive() -> None:
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/$defs/ShortName",
|
||||
"maxLength": 10,
|
||||
"maxLength": 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
"$defs": {"ShortName": {"type": "string", "maxLength": 5}},
|
||||
"$defs": {"ShortName": {"type": "string", "pattern": "^[A-Z]+$"}},
|
||||
},
|
||||
)
|
||||
schema = context_schema_for_node(workflow, "body")
|
||||
item = schema["properties"]["foreach"]["properties"]["names"]["properties"]["item"]
|
||||
validator = Draft202012Validator(item)
|
||||
|
||||
assert validator.is_valid("12345")
|
||||
assert not validator.is_valid("123456")
|
||||
assert validator.is_valid("ABC")
|
||||
assert not validator.is_valid("ABCD") # Sibling maxLength still applies.
|
||||
assert not validator.is_valid("abc") # Referenced pattern still applies.
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from wf_core.schema_navigation import SchemaNavigator
|
||||
|
||||
|
||||
def test_array_type_does_not_require_an_items_schema() -> None:
|
||||
"""Unconstrained array elements do not make the collection non-array."""
|
||||
view = SchemaNavigator({"type": "array"}).root
|
||||
|
||||
assert view.is_array()
|
||||
@@ -96,6 +96,61 @@ def test_active_structured_foreach_item_path_is_valid() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_context_foreach_source_rejects_declared_scalar() -> None:
|
||||
"""A readable context path is not iterable merely because it exists."""
|
||||
from wf_core.validation import validate_workflow
|
||||
|
||||
workflow = _base_workflow()
|
||||
workflow.nodes[1] = _foreach(
|
||||
"orders",
|
||||
over="context.foreach.customers.index",
|
||||
alias="order",
|
||||
)
|
||||
|
||||
report = validate_workflow(workflow)
|
||||
|
||||
assert (
|
||||
_issue(
|
||||
report,
|
||||
ValidationIssueCode.INVALID_FOREACH_SOURCE,
|
||||
"nodes[1].over",
|
||||
)
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def test_context_foreach_source_accepts_array_without_item_schema() -> None:
|
||||
"""An array remains iterable when its element type is unconstrained."""
|
||||
from wf_core.validation import validate_workflow
|
||||
|
||||
workflow = _base_workflow()
|
||||
workflow.state_schema = StateSchema.model_validate(
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"items": {"type": "array", "items": {"type": "array"}},
|
||||
"orders_list": {"type": "array"},
|
||||
},
|
||||
}
|
||||
)
|
||||
workflow.nodes[1] = _foreach(
|
||||
"orders",
|
||||
over="context.foreach.customers.item",
|
||||
alias="order",
|
||||
)
|
||||
|
||||
report = validate_workflow(workflow)
|
||||
|
||||
assert (
|
||||
_issue(
|
||||
report,
|
||||
ValidationIssueCode.INVALID_FOREACH_SOURCE,
|
||||
"nodes[1].over",
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_nested_body_can_read_outer_and_inner_entries() -> None:
|
||||
from wf_core.validation import validate_workflow
|
||||
|
||||
@@ -267,8 +322,6 @@ def test_all_model_surfaces_reject_missing_foreach_id() -> None:
|
||||
|
||||
# Foreach over
|
||||
workflow = _base_workflow()
|
||||
foreach = workflow.nodes[1]
|
||||
assert isinstance(foreach, ForeachNode)
|
||||
workflow.nodes[1] = ForeachNode.model_validate(
|
||||
{"id": "orders", "type": "foreach", "over": bad, "as": "order"}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user