Skip to content

Commit 3475b74

Browse files
authored
feat(core): initialize block ORM in core module (#4)
1 parent 391a7c9 commit 3475b74

4 files changed

Lines changed: 296 additions & 1 deletion

File tree

src/server/core/acontext_core/schema/orm/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from .session import Session
55
from .message import Message, Part, Asset, ToolCallMeta
66
from .task import Task
7+
from .block import Block
78

89
__all__ = [
910
"ORM_BASE",
@@ -15,4 +16,5 @@
1516
"ToolCallMeta",
1617
"Asset",
1718
"Task",
19+
"Block",
1820
]
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
import uuid
2+
from dataclasses import dataclass, field
3+
from sqlalchemy import String, ForeignKey, Index, CheckConstraint, Column, Boolean, BigInteger
4+
from sqlalchemy.orm import relationship, foreign, remote
5+
from sqlalchemy.dialects.postgresql import JSONB, UUID
6+
from typing import TYPE_CHECKING, Optional, List, Dict, Any
7+
from .base import ORM_BASE, CommonMixin
8+
from ..utils import asUUID
9+
10+
if TYPE_CHECKING:
11+
from .space import Space
12+
13+
14+
# Block type configuration matching Go version
15+
BLOCK_TYPES = {
16+
"page": {
17+
"name": "page",
18+
"allow_children": True,
19+
"require_parent": False,
20+
},
21+
"text": {
22+
"name": "text",
23+
"allow_children": True,
24+
"require_parent": True,
25+
},
26+
"snippet": {
27+
"name": "snippet",
28+
"allow_children": True,
29+
"require_parent": True,
30+
},
31+
}
32+
33+
# Block type constants matching Go version
34+
BLOCK_TYPE_PAGE = "page"
35+
BLOCK_TYPE_TEXT = "text"
36+
BLOCK_TYPE_SNIPPET = "snippet"
37+
38+
39+
def is_valid_block_type(block_type: str) -> bool:
40+
"""Check if the given type is valid"""
41+
return block_type in BLOCK_TYPES
42+
43+
44+
def get_block_type_config(block_type: str) -> Dict[str, Any]:
45+
"""Get the configuration of a block type"""
46+
if not is_valid_block_type(block_type):
47+
raise ValueError(f"invalid block type: {block_type}")
48+
return BLOCK_TYPES[block_type]
49+
50+
51+
def get_all_block_types() -> Dict[str, Dict[str, Any]]:
52+
"""Get all supported block types"""
53+
return BLOCK_TYPES
54+
55+
56+
@ORM_BASE.mapped
57+
@dataclass
58+
class Block(CommonMixin):
59+
__tablename__ = "blocks"
60+
61+
__table_args__ = (
62+
# Indexes matching Go version
63+
Index("idx_blocks_space", "space_id"),
64+
Index("idx_blocks_space_type", "space_id", "type"),
65+
Index("idx_blocks_space_type_archived", "space_id", "type", "is_archived"),
66+
# Unique constraint for space, parent, sort combination
67+
Index("ux_blocks_space_parent_sort", "space_id", "parent_id", "sort", unique=True),
68+
# Check constraints matching Go version
69+
CheckConstraint(
70+
"type IN ('page', 'text', 'snippet')",
71+
name="ck_block_type",
72+
),
73+
)
74+
75+
space_id: asUUID = field(
76+
metadata={
77+
"db": Column(
78+
UUID(as_uuid=True),
79+
ForeignKey("spaces.id", ondelete="CASCADE", onupdate="CASCADE"),
80+
nullable=False,
81+
)
82+
}
83+
)
84+
85+
type: str = field(
86+
metadata={
87+
"db": Column(
88+
String,
89+
nullable=False,
90+
)
91+
}
92+
)
93+
94+
parent_id: Optional[asUUID] = field(
95+
default=None,
96+
metadata={
97+
"db": Column(
98+
UUID(as_uuid=True),
99+
ForeignKey("blocks.id", ondelete="CASCADE", onupdate="CASCADE"),
100+
nullable=True,
101+
)
102+
},
103+
)
104+
105+
title: str = field(
106+
default="",
107+
metadata={
108+
"db": Column(
109+
String,
110+
nullable=False,
111+
default="",
112+
)
113+
},
114+
)
115+
116+
props: Dict[str, Any] = field(
117+
default_factory=dict,
118+
metadata={
119+
"db": Column(
120+
JSONB,
121+
nullable=False,
122+
default={},
123+
)
124+
},
125+
)
126+
127+
sort: int = field(
128+
default=0,
129+
metadata={
130+
"db": Column(
131+
BigInteger,
132+
nullable=False,
133+
default=0,
134+
)
135+
},
136+
)
137+
138+
is_archived: bool = field(
139+
default=False,
140+
metadata={
141+
"db": Column(
142+
Boolean,
143+
nullable=False,
144+
default=False,
145+
)
146+
},
147+
)
148+
149+
# Relationships
150+
space: "Space" = field(
151+
init=False,
152+
metadata={
153+
"db": relationship(
154+
"Space",
155+
back_populates="blocks",
156+
)
157+
},
158+
)
159+
160+
parent: Optional["Block"] = field(
161+
init=False,
162+
metadata={
163+
"db": relationship(
164+
"Block",
165+
remote_side=lambda: Block.id,
166+
foreign_keys=lambda: Block.parent_id,
167+
back_populates="children",
168+
lazy="select",
169+
)
170+
},
171+
)
172+
173+
children: List["Block"] = field(
174+
default_factory=list,
175+
init=False,
176+
metadata={
177+
"db": relationship(
178+
"Block",
179+
back_populates="parent",
180+
cascade="all, delete-orphan",
181+
lazy="selectin",
182+
)
183+
},
184+
)
185+
186+
def validate(self) -> None:
187+
"""Validate the fields of a Block"""
188+
# Check if the type is valid
189+
if not is_valid_block_type(self.type):
190+
raise ValueError(f"invalid block type: {self.type}")
191+
192+
config = get_block_type_config(self.type)
193+
194+
# Check the parent-child relationship constraints
195+
if config["require_parent"] and self.parent_id is None:
196+
raise ValueError(f"block type '{self.type}' requires a parent")
197+
198+
if not config["require_parent"] and self.type != BLOCK_TYPE_PAGE and self.parent_id is None:
199+
raise ValueError("only page type blocks can exist without a parent")
200+
201+
def validate_for_creation(self) -> None:
202+
"""Validate the constraints for creation"""
203+
self.validate()
204+
# Can add specific validation logic for creation here
205+
206+
def can_have_children(self) -> bool:
207+
"""Check if the block type can have children"""
208+
try:
209+
config = get_block_type_config(self.type)
210+
return config["allow_children"]
211+
except ValueError:
212+
return False

src/server/core/acontext_core/schema/orm/space.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
if TYPE_CHECKING:
1111
from .project import Project
1212
from .session import Session
13+
from .block import Block
1314

1415

1516
@ORM_BASE.mapped
@@ -42,3 +43,14 @@ class Space(CommonMixin):
4243
default_factory=list,
4344
metadata={"db": relationship("Session", back_populates="space")},
4445
)
46+
47+
blocks: List["Block"] = field(
48+
default_factory=list,
49+
metadata={
50+
"db": relationship(
51+
"Block",
52+
back_populates="space",
53+
cascade="all, delete-orphan",
54+
)
55+
},
56+
)

src/server/core/tests/deps/test_db.py

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from sqlalchemy import select
33
from sqlalchemy.orm import selectinload
44
from acontext_core.infra.db import DatabaseClient
5-
from acontext_core.schema.orm import Project, Space, Session
5+
from acontext_core.schema.orm import Project, Space, Session, Block
66

77
FAKE_KEY = "a" * 32
88

@@ -69,3 +69,72 @@ async def test_db():
6969
print(len(p_result.sessions), len(p_result.spaces))
7070
assert p_result.sessions[0].id == seid
7171
assert p_result.spaces[0].id == sid
72+
73+
# Test Block ORM functionality within the same test
74+
# Create a page block
75+
page_block = Block(
76+
space_id=sid,
77+
type="page",
78+
title="Test Page",
79+
props={"description": "A test page"},
80+
sort=0,
81+
)
82+
session.add(page_block)
83+
await session.flush()
84+
85+
# Create a text block under the page
86+
text_block = Block(
87+
space_id=sid,
88+
type="text",
89+
parent_id=page_block.id,
90+
title="Test Text",
91+
props={"content": "Hello World"},
92+
sort=1,
93+
)
94+
session.add(text_block)
95+
await session.commit() # Commit to ensure data is persisted
96+
97+
# Test Block relationships
98+
# Load space with blocks
99+
space_with_blocks_query = await session.execute(
100+
select(Space)
101+
.options(selectinload(Space.blocks))
102+
.where(Space.id == sid)
103+
)
104+
space_with_blocks = space_with_blocks_query.scalar_one()
105+
106+
assert len(space_with_blocks.blocks) == 2
107+
block_ids = [block.id for block in space_with_blocks.blocks]
108+
assert page_block.id in block_ids
109+
assert text_block.id in block_ids
110+
111+
# Test basic block properties
112+
assert page_block.type == "page"
113+
assert text_block.type == "text"
114+
assert text_block.parent_id == page_block.id
115+
116+
# Test Block self-referential relationships
117+
# Test parent relationship with selectinload
118+
text_query = await session.execute(
119+
select(Block)
120+
.options(selectinload(Block.parent))
121+
.where(Block.id == text_block.id)
122+
)
123+
text_result = text_query.scalar_one()
124+
125+
# Verify parent relationship works
126+
assert text_result.parent is not None
127+
assert text_result.parent.id == page_block.id
128+
129+
# Test children relationship (selectinload may not work, so use manual query)
130+
children_query = await session.execute(
131+
select(Block).where(Block.parent_id == page_block.id)
132+
)
133+
children = children_query.scalars().all()
134+
135+
# Verify children relationship works
136+
assert len(children) == 1
137+
assert children[0].id == text_block.id
138+
139+
print(f"Block test passed: page={page_block.id}, text={text_block.id}")
140+
print("✓ Self-referential relationships are working correctly!")

0 commit comments

Comments
 (0)