Skip to content

Commit 9bd6b67

Browse files
authored
feat(conversion): add LTable and KTable decoders for list and dict bindings (#767)
* feat(conversion): add LTable and KTable decoders for list and dict bindings * enforce strict decoding rules for LTable and KTable
1 parent eed25ec commit 9bd6b67

File tree

2 files changed

+206
-4
lines changed

2 files changed

+206
-4
lines changed

python/cocoindex/convert.py

Lines changed: 79 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -92,10 +92,14 @@ def make_engine_value_decoder(
9292
if src_type_kind == "Struct":
9393
return _make_engine_struct_to_dict_decoder(field_path, src_type["fields"])
9494
if src_type_kind in TABLE_TYPES:
95-
raise ValueError(
96-
f"Missing type annotation for `{''.join(field_path)}`."
97-
f"It's required for {src_type_kind} type."
98-
)
95+
if src_type_kind == "LTable":
96+
return _make_engine_ltable_to_list_dict_decoder(
97+
field_path, src_type["row"]["fields"]
98+
)
99+
elif src_type_kind == "KTable":
100+
return _make_engine_ktable_to_dict_dict_decoder(
101+
field_path, src_type["row"]["fields"]
102+
)
99103
return lambda value: value
100104

101105
# Handle struct -> dict binding for explicit dict annotations
@@ -340,6 +344,77 @@ def decode_to_dict(values: list[Any] | None) -> dict[str, Any] | None:
340344
return decode_to_dict
341345

342346

347+
def _make_engine_ltable_to_list_dict_decoder(
348+
field_path: list[str],
349+
src_fields: list[dict[str, Any]],
350+
) -> Callable[[list[Any] | None], list[dict[str, Any]] | None]:
351+
"""Make a decoder from engine LTable values to a list of dicts."""
352+
353+
# Create a decoder for each row (struct) to dict
354+
row_decoder = _make_engine_struct_to_dict_decoder(field_path, src_fields)
355+
356+
def decode_to_list_dict(values: list[Any] | None) -> list[dict[str, Any]] | None:
357+
if values is None:
358+
return None
359+
result = []
360+
for i, row_values in enumerate(values):
361+
decoded_row = row_decoder(row_values)
362+
if decoded_row is None:
363+
raise ValueError(
364+
f"LTable row at index {i} decoded to None, which is not allowed."
365+
)
366+
result.append(decoded_row)
367+
return result
368+
369+
return decode_to_list_dict
370+
371+
372+
def _make_engine_ktable_to_dict_dict_decoder(
373+
field_path: list[str],
374+
src_fields: list[dict[str, Any]],
375+
) -> Callable[[list[Any] | None], dict[Any, dict[str, Any]] | None]:
376+
"""Make a decoder from engine KTable values to a dict of dicts."""
377+
378+
if not src_fields:
379+
raise ValueError("KTable must have at least one field for the key")
380+
381+
# First field is the key, remaining fields are the value
382+
key_field_schema = src_fields[0]
383+
value_fields_schema = src_fields[1:]
384+
385+
# Create decoders
386+
field_path.append(f".{key_field_schema.get('name', KEY_FIELD_NAME)}")
387+
key_decoder = make_engine_value_decoder(field_path, key_field_schema["type"], Any)
388+
field_path.pop()
389+
390+
value_decoder = _make_engine_struct_to_dict_decoder(field_path, value_fields_schema)
391+
392+
def decode_to_dict_dict(
393+
values: list[Any] | None,
394+
) -> dict[Any, dict[str, Any]] | None:
395+
if values is None:
396+
return None
397+
result = {}
398+
for row_values in values:
399+
if not row_values:
400+
raise ValueError("KTable row must have at least 1 value (the key)")
401+
key = key_decoder(row_values[0])
402+
if len(row_values) == 1:
403+
value: dict[str, Any] = {}
404+
else:
405+
tmp = value_decoder(row_values[1:])
406+
if tmp is None:
407+
value = {}
408+
else:
409+
value = tmp
410+
if isinstance(key, dict):
411+
key = tuple(key.values())
412+
result[key] = value
413+
return result
414+
415+
return decode_to_dict_dict
416+
417+
343418
def dump_engine_object(v: Any) -> Any:
344419
"""Recursively dump an object for engine. Engine side uses `Pythonized` to catch."""
345420
if v is None:

python/cocoindex/tests/test_convert.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1341,3 +1341,130 @@ class Point(NamedTuple):
13411341
validate_full_roundtrip(
13421342
instance, Point, (expected_dict, dict), (expected_dict, Any)
13431343
)
1344+
1345+
1346+
def test_roundtrip_ltable_to_list_dict_binding() -> None:
1347+
"""Test LTable -> list[dict] binding with Any annotation."""
1348+
1349+
@dataclass
1350+
class User:
1351+
id: str
1352+
name: str
1353+
age: int
1354+
1355+
users = [User("u1", "Alice", 25), User("u2", "Bob", 30), User("u3", "Charlie", 35)]
1356+
expected_list_dict = [
1357+
{"id": "u1", "name": "Alice", "age": 25},
1358+
{"id": "u2", "name": "Bob", "age": 30},
1359+
{"id": "u3", "name": "Charlie", "age": 35},
1360+
]
1361+
1362+
# Test Any annotation
1363+
validate_full_roundtrip(users, list[User], (expected_list_dict, Any))
1364+
1365+
1366+
def test_roundtrip_ktable_to_dict_dict_binding() -> None:
1367+
"""Test KTable -> dict[K, dict] binding with Any annotation."""
1368+
1369+
@dataclass
1370+
class Product:
1371+
name: str
1372+
price: float
1373+
active: bool
1374+
1375+
products = {
1376+
"p1": Product("Widget", 29.99, True),
1377+
"p2": Product("Gadget", 49.99, False),
1378+
"p3": Product("Tool", 19.99, True),
1379+
}
1380+
expected_dict_dict = {
1381+
"p1": {"name": "Widget", "price": 29.99, "active": True},
1382+
"p2": {"name": "Gadget", "price": 49.99, "active": False},
1383+
"p3": {"name": "Tool", "price": 19.99, "active": True},
1384+
}
1385+
1386+
# Test Any annotation
1387+
validate_full_roundtrip(products, dict[str, Product], (expected_dict_dict, Any))
1388+
1389+
1390+
def test_roundtrip_ktable_with_complex_key() -> None:
1391+
"""Test KTable with complex key types -> dict binding."""
1392+
1393+
@dataclass(frozen=True)
1394+
class OrderKey:
1395+
shop_id: str
1396+
version: int
1397+
1398+
@dataclass
1399+
class Order:
1400+
customer: str
1401+
total: float
1402+
1403+
orders = {
1404+
OrderKey("shop1", 1): Order("Alice", 100.0),
1405+
OrderKey("shop2", 2): Order("Bob", 200.0),
1406+
}
1407+
expected_dict_dict = {
1408+
("shop1", 1): {"customer": "Alice", "total": 100.0},
1409+
("shop2", 2): {"customer": "Bob", "total": 200.0},
1410+
}
1411+
1412+
# Test Any annotation
1413+
validate_full_roundtrip(orders, dict[OrderKey, Order], (expected_dict_dict, Any))
1414+
1415+
1416+
def test_roundtrip_ltable_with_nested_structs() -> None:
1417+
"""Test LTable with nested structs -> list[dict] binding."""
1418+
1419+
@dataclass
1420+
class Address:
1421+
street: str
1422+
city: str
1423+
1424+
@dataclass
1425+
class Person:
1426+
name: str
1427+
age: int
1428+
address: Address
1429+
1430+
people = [
1431+
Person("John", 30, Address("123 Main St", "Anytown")),
1432+
Person("Jane", 25, Address("456 Oak Ave", "Somewhere")),
1433+
]
1434+
expected_list_dict = [
1435+
{
1436+
"name": "John",
1437+
"age": 30,
1438+
"address": {"street": "123 Main St", "city": "Anytown"},
1439+
},
1440+
{
1441+
"name": "Jane",
1442+
"age": 25,
1443+
"address": {"street": "456 Oak Ave", "city": "Somewhere"},
1444+
},
1445+
]
1446+
1447+
# Test Any annotation
1448+
validate_full_roundtrip(people, list[Person], (expected_list_dict, Any))
1449+
1450+
1451+
def test_roundtrip_ktable_with_list_fields() -> None:
1452+
"""Test KTable with list fields -> dict binding."""
1453+
1454+
@dataclass
1455+
class Team:
1456+
name: str
1457+
members: list[str]
1458+
active: bool
1459+
1460+
teams = {
1461+
"team1": Team("Dev Team", ["Alice", "Bob"], True),
1462+
"team2": Team("QA Team", ["Charlie", "David"], False),
1463+
}
1464+
expected_dict_dict = {
1465+
"team1": {"name": "Dev Team", "members": ["Alice", "Bob"], "active": True},
1466+
"team2": {"name": "QA Team", "members": ["Charlie", "David"], "active": False},
1467+
}
1468+
1469+
# Test Any annotation
1470+
validate_full_roundtrip(teams, dict[str, Team], (expected_dict_dict, Any))

0 commit comments

Comments
 (0)