diff --git a/packages/cli/tests/bindings-test/src/test_do.py b/packages/cli/tests/bindings-test/src/test_do.py index 414d5f0..e015131 100644 --- a/packages/cli/tests/bindings-test/src/test_do.py +++ b/packages/cli/tests/bindings-test/src/test_do.py @@ -91,6 +91,18 @@ async def test_sql_cursor_rows_read_written(env): await stub.test_sql_cursor_rows_read_written() +@pytest.mark.asyncio +async def test_sql_cursor_iter(env): + stub = await _get_stub(env) + await stub.test_sql_cursor_iter() + + +@pytest.mark.asyncio +async def test_sql_cursor_toarray_getitem_int(env): + stub = await _get_stub(env) + await stub.test_sql_cursor_toarray_getitem_int() + + @pytest.mark.asyncio async def test_sql_database_size(env): stub = await _get_stub(env) diff --git a/packages/cli/tests/bindings-test/src/test_kv.py b/packages/cli/tests/bindings-test/src/test_kv.py index 3f42353..d40a1f6 100644 --- a/packages/cli/tests/bindings-test/src/test_kv.py +++ b/packages/cli/tests/bindings-test/src/test_kv.py @@ -298,3 +298,18 @@ async def test_none_options_list(env): await kv.put("_test:none_list", "val") result = await kv.list(None) assert result["list_complete"] is True + + +@pytest.mark.asyncio +async def test_binding_not_iterable(env): + kv = env.KV + with pytest.raises(TypeError, match="KvNamespace.*is not iterable"): + for _ in kv: + pass + + +@pytest.mark.asyncio +async def test_binding_no_len(env): + kv = env.KV + with pytest.raises(TypeError, match="KvNamespace.*has no len"): + len(kv) diff --git a/packages/cli/tests/bindings-test/src/worker_durable_object.py b/packages/cli/tests/bindings-test/src/worker_durable_object.py index d1e81e9..d32093f 100644 --- a/packages/cli/tests/bindings-test/src/worker_durable_object.py +++ b/packages/cli/tests/bindings-test/src/worker_durable_object.py @@ -1,3 +1,5 @@ +from contextlib import contextmanager + from workers import DurableObject @@ -201,6 +203,51 @@ async def test_rpc_echo(self, value): async def test_rpc_dict(self, data): return {"received": data, "added": True} + @contextmanager + def _create_iter_table(self): + self.ctx.storage.sql.exec("DROP TABLE IF EXISTS test_iter") + self.ctx.storage.sql.exec( + "CREATE TABLE test_iter (id INTEGER PRIMARY KEY, val TEXT)" + ) + self.ctx.storage.sql.exec( + "INSERT INTO test_iter (id, val) VALUES (?, ?)", 1, "alpha" + ) + self.ctx.storage.sql.exec( + "INSERT INTO test_iter (id, val) VALUES (?, ?)", 2, "beta" + ) + self.ctx.storage.sql.exec( + "INSERT INTO test_iter (id, val) VALUES (?, ?)", 3, "gamma" + ) + try: + yield + finally: + self.ctx.storage.sql.exec("DROP TABLE IF EXISTS test_iter") + + async def test_sql_cursor_iter(self): + with self._create_iter_table(): + cursor = self.ctx.storage.sql.exec( + "SELECT id, val FROM test_iter ORDER BY id" + ) + rows = [{"id": row["id"], "val": row["val"]} for row in cursor] + del cursor + assert len(rows) == 3, f"expected 3 rows, got {len(rows)}" + assert rows[0]["id"] == 1 and rows[0]["val"] == "alpha" + assert rows[1]["id"] == 2 and rows[1]["val"] == "beta" + assert rows[2]["id"] == 3 and rows[2]["val"] == "gamma" + + async def test_sql_cursor_toarray_getitem_int(self): + with self._create_iter_table(): + cursor = self.ctx.storage.sql.exec( + "SELECT id, val FROM test_iter ORDER BY id" + ) + arr = cursor.toArray() + del cursor + first_row = arr[0] + assert first_row["id"] == 1 and first_row["val"] == "alpha", ( + f"expected row with id=1, got {first_row!r}" + ) + assert len(arr) == 3, f"expected len 3, got {len(arr)}" + async def test_storage_value_types(self): await self.ctx.storage.deleteAll() await self.ctx.storage.put("str", "hello") diff --git a/packages/runtime-sdk/src/workers/_workers.py b/packages/runtime-sdk/src/workers/_workers.py index f55b603..d9603b3 100644 --- a/packages/runtime-sdk/src/workers/_workers.py +++ b/packages/runtime-sdk/src/workers/_workers.py @@ -1122,6 +1122,14 @@ class _BindingWrapper: def __init__(self, binding): self._binding = binding + @property + def _real_name(self): + js_name = _get_js_constructor_name(self._binding) + if not js_name: + # Should not happen, but just in case + return type(self).__name__ + return js_name + def _should_wrap_nested_attribute(self, jsobj) -> bool: if not isinstance(jsobj, JsProxy): return False @@ -1175,8 +1183,23 @@ def __getattr__(self, name): return result def __getitem__(self, key): + if isinstance(key, int): + return self._convert_result(self._binding[key]) return self._convert_result(getattr(self._binding, key)) + def __iter__(self): + binding = self._binding + if not hasattr(binding, "__iter__"): + raise TypeError(f"'{self._real_name}' object is not iterable") + for item in binding: + yield self._convert_result(item) + + def __len__(self): + binding = self._binding + if not hasattr(binding, "length"): + raise TypeError(f"'{self._real_name}' object has no len()") + return binding.length + class _FetcherWrapper(_BindingWrapper): def fetch(self, *args, **kwargs):