Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions datamimic_ce/authoring/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
EL_SETUP,
EL_STATE_MACHINE,
EL_TRANSITION,
EL_VALUE,
EL_VARIABLE,
EL_WHILE,
)
Expand All @@ -72,6 +73,7 @@
from datamimic_ce.model.reference_model import ReferenceModel
from datamimic_ce.model.setup_model import SetupModel
from datamimic_ce.model.state_machine_model import StateMachineModel
from datamimic_ce.model.value_model import ValueModel
from datamimic_ce.model.variable_model import VariableModel
from datamimic_ce.model.while_model import WhileModel
from datamimic_ce.parsers.parser_util import ParserUtil
Expand All @@ -88,6 +90,7 @@
EL_VARIABLE: VariableModel,
EL_NESTED_KEY: NestedKeyModel,
EL_ARRAY: ArrayModel,
EL_VALUE: ValueModel, # <array type="literal"> child only
EL_LIST: ListModel,
EL_ITEM: ItemModel,
EL_REFERENCE: ReferenceModel,
Expand Down
1 change: 1 addition & 0 deletions datamimic_ce/constants/data_type_constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@
DATA_TYPE_BINARY = "binary" # random bytes; base64 at file-export boundaries, raw bytes to DBs
DATA_TYPE_LIST = "list"
DATA_TYPE_DICT = "dict"
DATA_TYPE_LITERAL = "literal" # <array type="literal">: preserve <value constant=...> children exactly
1 change: 1 addition & 0 deletions datamimic_ce/constants/element_constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
EL_VARIABLE = "variable"
EL_NESTED_KEY = "nestedKey"
EL_ARRAY = "array"
EL_VALUE = "value" # <array type="literal"> child: <value constant="..."/>
EL_INCLUDE = "include"
EL_MEMSTORE = "memstore"
EL_EXECUTE = "execute"
Expand Down
29 changes: 22 additions & 7 deletions datamimic_ce/model/array_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,23 @@
from pydantic import BaseModel, field_validator, model_validator

from datamimic_ce.constants.attribute_constants import ATTR_COUNT, ATTR_NAME, ATTR_SCRIPT, ATTR_TYPE
from datamimic_ce.constants.data_type_constants import DATA_TYPE_BOOL, DATA_TYPE_FLOAT, DATA_TYPE_INT, DATA_TYPE_STRING
from datamimic_ce.constants.data_type_constants import (
DATA_TYPE_BOOL,
DATA_TYPE_FLOAT,
DATA_TYPE_INT,
DATA_TYPE_LITERAL,
DATA_TYPE_STRING,
)
from datamimic_ce.model.model_util import ModelUtil

ALLOWED_ARRAY_TYPES = {
DATA_TYPE_STRING,
DATA_TYPE_INT,
DATA_TYPE_BOOL,
DATA_TYPE_FLOAT,
DATA_TYPE_LITERAL,
}


class ArrayModel(BaseModel):
name: str
Expand Down Expand Up @@ -42,6 +56,12 @@ def check_script_mode(cls, values: dict) -> dict:
:return:
"""
key_set = set(values.keys())
if values.get(ATTR_TYPE) == DATA_TYPE_LITERAL:
if ATTR_SCRIPT in key_set:
raise ValueError(f"'{ATTR_SCRIPT}' must not be defined with {ATTR_TYPE} '{DATA_TYPE_LITERAL}'")
if ATTR_COUNT in key_set:
raise ValueError(f"'{ATTR_COUNT}' must not be defined with {ATTR_TYPE} '{DATA_TYPE_LITERAL}'")
return values
if ATTR_SCRIPT in key_set:
if ATTR_COUNT in key_set or ATTR_TYPE in key_set:
raise ValueError(f"'{ATTR_COUNT}' and '{ATTR_TYPE}' must not be defined with {ATTR_SCRIPT}")
Expand All @@ -68,12 +88,7 @@ def validate_attribute_data_type(cls, value):
"""
return ModelUtil.check_valid_data_value(
value=value,
valid_values={
DATA_TYPE_STRING,
DATA_TYPE_INT,
DATA_TYPE_BOOL,
DATA_TYPE_FLOAT,
},
valid_values=ALLOWED_ARRAY_TYPES,
)

@field_validator("name")
Expand Down
26 changes: 26 additions & 0 deletions datamimic_ce/model/value_model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# DATAMIMIC
# Copyright (c) 2023-2025 Rapiddweller Asia Co., Ltd.
# This software is licensed under the MIT License.
# See LICENSE file for the full text of the license.
# For questions and support, contact: info@rapiddweller.com

"""Pydantic model for literal <value> entries inside <array type="literal">."""

from pydantic import BaseModel, field_validator, model_validator

from datamimic_ce.constants.attribute_constants import ATTR_CONSTANT
from datamimic_ce.model.model_util import ModelUtil


class ValueModel(BaseModel):
constant: str

@model_validator(mode="before")
@classmethod
def check_valid_attributes(cls, values: dict) -> dict:
return ModelUtil.check_valid_attributes(values=values, valid_attributes={ATTR_CONSTANT})

@field_validator("constant")
@classmethod
def validate_constant(cls, value: str) -> str:
return ModelUtil.check_not_empty(value=value)
36 changes: 35 additions & 1 deletion datamimic_ce/parsers/array_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,13 @@

from xml.etree.ElementTree import Element

from pydantic import ValidationError

from datamimic_ce.constants.attribute_constants import ATTR_NAME
from datamimic_ce.constants.data_type_constants import DATA_TYPE_LITERAL
from datamimic_ce.constants.element_constants import EL_ARRAY
from datamimic_ce.model.array_model import ArrayModel
from datamimic_ce.model.value_model import ValueModel
from datamimic_ce.parsers.statement_parser import StatementParser
from datamimic_ce.statements.array_statement import ArrayStatement

Expand All @@ -29,5 +34,34 @@ def parse(self, **kwargs) -> ArrayStatement:
Parse element "array" to ArrayStatement
:return:
"""
model = self.validate_attributes(ArrayModel)

if model.type == DATA_TYPE_LITERAL:
return ArrayStatement(model, self._parse_literal_values())
if len(self._element) > 0:
raise ValueError(
f"<array> '{model.name}' has child elements but is not type='{DATA_TYPE_LITERAL}' - "
f"sub-elements are only valid for a literal array"
)
return ArrayStatement(model)

def _parse_literal_values(self) -> list[str]:
from datamimic_ce.parsers.parser_util import ParserUtil

parsed_values: list[str] = []
for child in self._element:
attributes = ParserUtil.retrieve_element_attributes(child.attrib, self._properties)
try:
value_model = ValueModel(**attributes)
except ValidationError as err:
raise ValueError(
f"Invalid <{child.tag}> inside <array> '{self._element.get(ATTR_NAME)}': {err}"
) from err
parsed_values.append(value_model.constant)

return ArrayStatement(self.validate_attributes(ArrayModel))
if not parsed_values:
raise ValueError(
f"<array> '{self._element.get(ATTR_NAME)}' with type='{DATA_TYPE_LITERAL}' "
f"must have at least one <value> child"
)
return parsed_values
4 changes: 4 additions & 0 deletions datamimic_ce/parsers/parser_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
EL_SETUP,
EL_STATE_MACHINE,
EL_TRANSITION,
EL_VALUE,
EL_VARIABLE,
EL_WHILE,
)
Expand Down Expand Up @@ -154,6 +155,9 @@ def get_valid_sub_elements_set_by_tag(ele_tag: str) -> set | None:
EL_ITEM: {EL_KEY, EL_ID, EL_NESTED_KEY, EL_LIST, EL_ARRAY, EL_ELEMENT},
EL_KEY: {EL_ELEMENT},
EL_LIST: {EL_ITEM},
# <value> only valid inside type="literal" arrays; ArrayParser enforces that, not this
# generic tag-set (which only says "the tag is structurally allowed here").
EL_ARRAY: {EL_VALUE},
EL_IF: None,
EL_ELSE_IF: None,
EL_ELSE: None,
Expand Down
7 changes: 6 additions & 1 deletion datamimic_ce/statements/array_statement.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,12 @@


class ArrayStatement(Statement):
def __init__(self, model: ArrayModel):
def __init__(self, model: ArrayModel, literal_values: list[str] | None = None):
super().__init__(model.name, None)
self._count = model.count
self._type = model.type
self._script = model.script
self._literal_values = list(literal_values or [])

@property
def type(self) -> str | None:
Expand All @@ -27,3 +28,7 @@ def count(self) -> int | None:
@property
def script(self) -> str | None:
return self._script

@property
def literal_values(self) -> list[str]:
return self._literal_values
16 changes: 14 additions & 2 deletions datamimic_ce/tasks/array_task.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,13 @@

from decimal import Decimal

from datamimic_ce.constants.data_type_constants import DATA_TYPE_BOOL, DATA_TYPE_FLOAT, DATA_TYPE_INT, DATA_TYPE_STRING
from datamimic_ce.constants.data_type_constants import (
DATA_TYPE_BOOL,
DATA_TYPE_FLOAT,
DATA_TYPE_INT,
DATA_TYPE_LITERAL,
DATA_TYPE_STRING,
)
from datamimic_ce.contexts.geniter_context import GenIterContext
from datamimic_ce.statements.array_statement import ArrayStatement
from datamimic_ce.tasks.task import GenSubTask
Expand All @@ -33,11 +39,17 @@ def execute(self, parent_context: GenIterContext) -> None:
:param parent_context:
:return: None
"""
if self._statement.script:
if self._statement.type == DATA_TYPE_LITERAL:
self._execute_literal_generate(parent_context)
elif self._statement.script:
self._execute_script_generate(parent_context)
else:
self._execute_type_generate(parent_context)

def _execute_literal_generate(self, parent_context: GenIterContext) -> None:
"""Preserve literal array values exactly - no random generation, no script evaluation."""
parent_context.add_current_product_field(self._statement.name, list(self._statement.literal_values))

def _execute_type_generate(self, parent_context: GenIterContext) -> None:
"""
Create new data for path
Expand Down
39 changes: 39 additions & 0 deletions tests_ce/integration_tests/test_array/test_array.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,42 @@ def test_array_invalid_type(self, test_dir: Path) -> None:
def test_array_script(self, test_dir: Path) -> None:
test_engine = DataMimicTest(test_dir=test_dir, filename="test_array_script.xml")
test_engine.test_with_timer()

def test_array_literal(self, test_dir: Path) -> None:
"""type='literal' with <value constant=...> children preserves values exactly - no
random generation, no script evaluation (EE parity, docs/specs/model/elements/08-array.md)."""
test_engine = DataMimicTest(test_dir=test_dir, filename="test_array_literal.xml", capture_test_result=True)
test_engine.test_with_timer()
result = test_engine.capture_result()
assert result["data"][0]["status_codes"] == ["001", "002", "099"]

def test_array_literal_conflicting_attrs(self, test_dir: Path) -> None:
"""'count'/'script' must not be defined together with type='literal'."""
test_engine = DataMimicTest(test_dir=test_dir, filename="test_array_literal_conflicting_attrs.xml")
with pytest.raises(ValueError):
test_engine.test_with_timer()

def test_array_literal_with_script_conflicting_attrs(self, test_dir: Path) -> None:
"""'script' must not be defined together with type='literal' either."""
test_engine = DataMimicTest(test_dir=test_dir, filename="test_array_literal_with_script.xml")
with pytest.raises(ValueError):
test_engine.test_with_timer()

def test_array_non_literal_with_children(self, test_dir: Path) -> None:
"""<value> children are only valid for type='literal'; a typed/scripted array with
children is rejected."""
test_engine = DataMimicTest(test_dir=test_dir, filename="test_array_non_literal_with_children.xml")
with pytest.raises(ValueError, match="sub-elements are only valid"):
test_engine.test_with_timer()

def test_array_literal_invalid_value_child(self, test_dir: Path) -> None:
"""A <value> child missing its mandatory 'constant' attribute fails parsing."""
test_engine = DataMimicTest(test_dir=test_dir, filename="test_array_literal_invalid_value_child.xml")
with pytest.raises(ValueError):
test_engine.test_with_timer()

def test_array_literal_empty(self, test_dir: Path) -> None:
"""A literal array must have at least one <value> child."""
test_engine = DataMimicTest(test_dir=test_dir, filename="test_array_literal_empty.xml")
with pytest.raises(ValueError, match="must have at least one"):
test_engine.test_with_timer()
9 changes: 9 additions & 0 deletions tests_ce/integration_tests/test_array/test_array_literal.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<setup>
<generate name="data" target="ConsoleExporter" count="1">
<array name="status_codes" type="literal">
<value constant="001"/>
<value constant="002"/>
<value constant="099"/>
</array>
</generate>
</setup>
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<setup>
<generate name="data" target="ConsoleExporter" count="1">
<array name="status_codes" type="literal" count="3">
<value constant="001"/>
</array>
</generate>
</setup>
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<setup>
<generate name="data" target="ConsoleExporter" count="1">
<array name="status_codes" type="literal">
</array>
</generate>
</setup>
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<setup>
<generate name="data" target="ConsoleExporter" count="1">
<array name="status_codes" type="literal">
<value/>
</array>
</generate>
</setup>
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<setup>
<generate name="data" target="ConsoleExporter" count="1">
<array name="status_codes" type="literal" script="['001']">
<value constant="001"/>
</array>
</generate>
</setup>
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<setup>
<generate name="data" target="ConsoleExporter" count="1">
<array name="x" type="int" count="3">
<value constant="1"/>
</array>
</generate>
</setup>
9 changes: 5 additions & 4 deletions tests_ce/unit_tests/test_authoring/test_schema_index.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@

from datamimic_ce.authoring.rules import ALL_RULES, best_practice, cross_statement, schema_rules, semantic_rules
from datamimic_ce.authoring.schema import ELEMENT_MODEL_MAP, build_schema_index
from datamimic_ce.constants.element_constants import EL_COMMENT, EL_FIELD, EL_SETUP, EL_TRANSITION
from datamimic_ce.constants.element_constants import EL_COMMENT, EL_FIELD, EL_SETUP, EL_TRANSITION, EL_VALUE
from datamimic_ce.model.model_util import ModelUtil
from datamimic_ce.parsers.parser_util import ParserUtil

Expand Down Expand Up @@ -88,13 +88,14 @@ def spy(values: dict, valid_attributes: set) -> dict:

def test_gate2_dispatch_accepts_exactly_the_mapped_tags() -> None:
# <setup> is the root (parsed by SetupParser directly); <transition> is parsed
# inside <state-machine>, <field> inside <reference> — none is dispatched standalone.
non_dispatchable = (EL_SETUP, EL_COMMENT, EL_TRANSITION, EL_FIELD)
# inside <state-machine>, <field> inside <reference>, <value> inside a literal
# <array> — none is dispatched standalone.
non_dispatchable = (EL_SETUP, EL_COMMENT, EL_TRANSITION, EL_FIELD, EL_VALUE)
dispatchable = {tag for tag in ELEMENT_MODEL_MAP if tag not in non_dispatchable}
for tag in sorted(dispatchable):
parser = ParserUtil._get_parser_by_element(ET.Element(tag), properties=None)
assert parser is not None, f"<{tag}> is in ELEMENT_MODEL_MAP but the engine cannot dispatch it"
for tag in (EL_TRANSITION, EL_FIELD, "definitely_not_an_element"):
for tag in (EL_TRANSITION, EL_FIELD, EL_VALUE, "definitely_not_an_element"):
with pytest.raises(ValueError):
ParserUtil._get_parser_by_element(ET.Element(tag), properties=None)

Expand Down