feat(api): Introduce `WorkflowDraftVariable` Model (#19737)
- Introduce `WorkflowDraftVariable` model and the corresponding migration. - Implement `EnumText`, a custom column type for SQLAlchemy designed to work seamlessly with enumeration classes based on `StrEnum`.pull/19948/head
parent
bbebf9ad3e
commit
6a9e0b1005
@ -0,0 +1,7 @@
|
|||||||
|
# The minimal selector length for valid variables.
|
||||||
|
#
|
||||||
|
# The first element of the selector is the node id, and the second element is the variable name.
|
||||||
|
#
|
||||||
|
# If the selector length is more than 2, the remaining parts are the keys / indexes paths used
|
||||||
|
# to extract part of the variable value.
|
||||||
|
MIN_SELECTORS_LENGTH = 2
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
from collections.abc import Iterable, Sequence
|
||||||
|
|
||||||
|
|
||||||
|
def to_selector(node_id: str, name: str, paths: Iterable[str] = ()) -> Sequence[str]:
|
||||||
|
selectors = [node_id, name]
|
||||||
|
if paths:
|
||||||
|
selectors.extend(paths)
|
||||||
|
return selectors
|
||||||
@ -0,0 +1,51 @@
|
|||||||
|
"""add WorkflowDraftVariable model
|
||||||
|
|
||||||
|
Revision ID: 2adcbe1f5dfb
|
||||||
|
Revises: d28f2004b072
|
||||||
|
Create Date: 2025-05-15 15:31:03.128680
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
import models as models
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "2adcbe1f5dfb"
|
||||||
|
down_revision = "d28f2004b072"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table(
|
||||||
|
"workflow_draft_variables",
|
||||||
|
sa.Column("id", models.types.StringUUID(), server_default=sa.text("uuid_generate_v4()"), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
|
||||||
|
sa.Column("app_id", models.types.StringUUID(), nullable=False),
|
||||||
|
sa.Column("last_edited_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.Column("node_id", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("name", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("description", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("selector", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("value_type", sa.String(length=20), nullable=False),
|
||||||
|
sa.Column("value", sa.Text(), nullable=False),
|
||||||
|
sa.Column("visible", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("editable", sa.Boolean(), nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint("id", name=op.f("workflow_draft_variables_pkey")),
|
||||||
|
sa.UniqueConstraint("app_id", "node_id", "name", name=op.f("workflow_draft_variables_app_id_key")),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
|
||||||
|
# Dropping `workflow_draft_variables` also drops any index associated with it.
|
||||||
|
op.drop_table("workflow_draft_variables")
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
@ -0,0 +1,187 @@
|
|||||||
|
from collections.abc import Callable, Iterable
|
||||||
|
from enum import StrEnum
|
||||||
|
from typing import Any, NamedTuple, TypeVar
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import exc as sa_exc
|
||||||
|
from sqlalchemy import insert
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, Mapped, Session
|
||||||
|
from sqlalchemy.sql.sqltypes import VARCHAR
|
||||||
|
|
||||||
|
from models.types import EnumText
|
||||||
|
|
||||||
|
_user_type_admin = "admin"
|
||||||
|
_user_type_normal = "normal"
|
||||||
|
|
||||||
|
|
||||||
|
class _Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class _UserType(StrEnum):
|
||||||
|
admin = _user_type_admin
|
||||||
|
normal = _user_type_normal
|
||||||
|
|
||||||
|
|
||||||
|
class _EnumWithLongValue(StrEnum):
|
||||||
|
unknown = "unknown"
|
||||||
|
a_really_long_enum_values = "a_really_long_enum_values"
|
||||||
|
|
||||||
|
|
||||||
|
class _User(_Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id: Mapped[int] = sa.Column(sa.Integer, primary_key=True)
|
||||||
|
name: Mapped[str] = sa.Column(sa.String(length=255), nullable=False)
|
||||||
|
user_type: Mapped[_UserType] = sa.Column(EnumText(enum_class=_UserType), nullable=False, default=_UserType.normal)
|
||||||
|
user_type_nullable: Mapped[_UserType | None] = sa.Column(EnumText(enum_class=_UserType), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class _ColumnTest(_Base):
|
||||||
|
__tablename__ = "column_test"
|
||||||
|
|
||||||
|
id: Mapped[int] = sa.Column(sa.Integer, primary_key=True)
|
||||||
|
|
||||||
|
user_type: Mapped[_UserType] = sa.Column(EnumText(enum_class=_UserType), nullable=False, default=_UserType.normal)
|
||||||
|
explicit_length: Mapped[_UserType | None] = sa.Column(
|
||||||
|
EnumText(_UserType, length=50), nullable=True, default=_UserType.normal
|
||||||
|
)
|
||||||
|
long_value: Mapped[_EnumWithLongValue] = sa.Column(EnumText(enum_class=_EnumWithLongValue), nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
_T = TypeVar("_T")
|
||||||
|
|
||||||
|
|
||||||
|
def _first(it: Iterable[_T]) -> _T:
|
||||||
|
ls = list(it)
|
||||||
|
if not ls:
|
||||||
|
raise ValueError("List is empty")
|
||||||
|
return ls[0]
|
||||||
|
|
||||||
|
|
||||||
|
class TestEnumText:
|
||||||
|
def test_column_impl(self):
|
||||||
|
engine = sa.create_engine("sqlite://", echo=False)
|
||||||
|
_Base.metadata.create_all(engine)
|
||||||
|
|
||||||
|
inspector = sa.inspect(engine)
|
||||||
|
columns = inspector.get_columns(_ColumnTest.__tablename__)
|
||||||
|
|
||||||
|
user_type_column = _first(c for c in columns if c["name"] == "user_type")
|
||||||
|
sql_type = user_type_column["type"]
|
||||||
|
assert isinstance(user_type_column["type"], VARCHAR)
|
||||||
|
assert sql_type.length == 20
|
||||||
|
assert user_type_column["nullable"] is False
|
||||||
|
|
||||||
|
explicit_length_column = _first(c for c in columns if c["name"] == "explicit_length")
|
||||||
|
sql_type = explicit_length_column["type"]
|
||||||
|
assert isinstance(sql_type, VARCHAR)
|
||||||
|
assert sql_type.length == 50
|
||||||
|
assert explicit_length_column["nullable"] is True
|
||||||
|
|
||||||
|
long_value_column = _first(c for c in columns if c["name"] == "long_value")
|
||||||
|
sql_type = long_value_column["type"]
|
||||||
|
assert isinstance(sql_type, VARCHAR)
|
||||||
|
assert sql_type.length == len(_EnumWithLongValue.a_really_long_enum_values)
|
||||||
|
|
||||||
|
def test_insert_and_select(self):
|
||||||
|
engine = sa.create_engine("sqlite://", echo=False)
|
||||||
|
_Base.metadata.create_all(engine)
|
||||||
|
|
||||||
|
with Session(engine) as session:
|
||||||
|
admin_user = _User(
|
||||||
|
name="admin",
|
||||||
|
user_type=_UserType.admin,
|
||||||
|
user_type_nullable=None,
|
||||||
|
)
|
||||||
|
session.add(admin_user)
|
||||||
|
session.flush()
|
||||||
|
admin_user_id = admin_user.id
|
||||||
|
|
||||||
|
normal_user = _User(
|
||||||
|
name="normal",
|
||||||
|
user_type=_UserType.normal.value,
|
||||||
|
user_type_nullable=_UserType.normal.value,
|
||||||
|
)
|
||||||
|
session.add(normal_user)
|
||||||
|
session.flush()
|
||||||
|
normal_user_id = normal_user.id
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
with Session(engine) as session:
|
||||||
|
user = session.query(_User).filter(_User.id == admin_user_id).first()
|
||||||
|
assert user.user_type == _UserType.admin
|
||||||
|
assert user.user_type_nullable is None
|
||||||
|
|
||||||
|
with Session(engine) as session:
|
||||||
|
user = session.query(_User).filter(_User.id == normal_user_id).first()
|
||||||
|
assert user.user_type == _UserType.normal
|
||||||
|
assert user.user_type_nullable == _UserType.normal
|
||||||
|
|
||||||
|
def test_insert_invalid_values(self):
|
||||||
|
def _session_insert_with_value(sess: Session, user_type: Any):
|
||||||
|
user = _User(name="test_user", user_type=user_type)
|
||||||
|
sess.add(user)
|
||||||
|
sess.flush()
|
||||||
|
|
||||||
|
def _insert_with_user(sess: Session, user_type: Any):
|
||||||
|
stmt = insert(_User).values(
|
||||||
|
{
|
||||||
|
"name": "test_user",
|
||||||
|
"user_type": user_type,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
sess.execute(stmt)
|
||||||
|
|
||||||
|
class TestCase(NamedTuple):
|
||||||
|
name: str
|
||||||
|
action: Callable[[Session], None]
|
||||||
|
exc_type: type[Exception]
|
||||||
|
|
||||||
|
engine = sa.create_engine("sqlite://", echo=False)
|
||||||
|
_Base.metadata.create_all(engine)
|
||||||
|
cases = [
|
||||||
|
TestCase(
|
||||||
|
name="session insert with invalid value",
|
||||||
|
action=lambda s: _session_insert_with_value(s, "invalid"),
|
||||||
|
exc_type=ValueError,
|
||||||
|
),
|
||||||
|
TestCase(
|
||||||
|
name="session insert with invalid type",
|
||||||
|
action=lambda s: _session_insert_with_value(s, 1),
|
||||||
|
exc_type=TypeError,
|
||||||
|
),
|
||||||
|
TestCase(
|
||||||
|
name="insert with invalid value",
|
||||||
|
action=lambda s: _insert_with_user(s, "invalid"),
|
||||||
|
exc_type=ValueError,
|
||||||
|
),
|
||||||
|
TestCase(
|
||||||
|
name="insert with invalid type",
|
||||||
|
action=lambda s: _insert_with_user(s, 1),
|
||||||
|
exc_type=TypeError,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
for idx, c in enumerate(cases, 1):
|
||||||
|
with pytest.raises(sa_exc.StatementError) as exc:
|
||||||
|
with Session(engine) as session:
|
||||||
|
c.action(session)
|
||||||
|
|
||||||
|
assert isinstance(exc.value.orig, c.exc_type), f"test case {idx} failed, name={c.name}"
|
||||||
|
|
||||||
|
def test_select_invalid_values(self):
|
||||||
|
engine = sa.create_engine("sqlite://", echo=False)
|
||||||
|
_Base.metadata.create_all(engine)
|
||||||
|
|
||||||
|
insertion_sql = """
|
||||||
|
INSERT INTO users (id, name, user_type) VALUES
|
||||||
|
(1, 'invalid_value', 'invalid');
|
||||||
|
"""
|
||||||
|
with Session(engine) as session:
|
||||||
|
session.execute(sa.text(insertion_sql))
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
with pytest.raises(ValueError) as exc:
|
||||||
|
with Session(engine) as session:
|
||||||
|
_user = session.query(_User).filter(_User.id == 1).first()
|
||||||
Loading…
Reference in New Issue