From 47eefd0fe5f2d0e7a72e1ef85179e2b87629f826 Mon Sep 17 00:00:00 2001 From: biondizzle Date: Sat, 21 Feb 2026 19:17:36 -0500 Subject: [PATCH] house keeping --- Dockerfile.test_sdk | 64 -- Makefile | 91 --- QUICKSTART.md | 1 - docker-compose-python-sdk-test.yaml | 14 - test_sdk.py | 1002 --------------------------- 5 files changed, 1172 deletions(-) delete mode 100644 Dockerfile.test_sdk delete mode 100644 docker-compose-python-sdk-test.yaml delete mode 100644 test_sdk.py diff --git a/Dockerfile.test_sdk b/Dockerfile.test_sdk deleted file mode 100644 index 2fcdaa2..0000000 --- a/Dockerfile.test_sdk +++ /dev/null @@ -1,64 +0,0 @@ -# Multi-stage build for Odin + Python test environment with RocksDB -FROM debian:bookworm-slim AS odin-builder - -# Install dependencies for building Odin -RUN apt-get update && apt-get install -y \ - git \ - curl \ - build-essential \ - clang \ - llvm \ - && rm -rf /var/lib/apt/lists/* - -# Install Odin compiler -WORKDIR /opt -RUN git clone https://github.com/odin-lang/Odin.git odin \ - && cd odin \ - && ./build_odin.sh release - -# Final stage with both Odin and Python -FROM python:3.12-slim - -# Install runtime and build dependencies including RocksDB -RUN apt-get update && apt-get install -y \ - clang \ - llvm \ - make \ - git \ - build-essential \ - cmake \ - pkg-config \ - # RocksDB and compression libraries - librocksdb-dev \ - librocksdb8.7 \ - # Compression libraries that RocksDB depends on - libsnappy-dev \ - libgflags-dev \ - libz-dev \ - libbz2-dev \ - liblz4-dev \ - libzstd-dev \ - # Additional common dependencies - libssl-dev \ - libcurl4-openssl-dev \ - && rm -rf /var/lib/apt/lists/* - -# Copy Odin compiler from builder stage -COPY --from=odin-builder /opt/odin /opt/odin - -# Add Odin to PATH -ENV PATH="/opt/odin:${PATH}" -ENV ODIN_ROOT="/opt/odin" - -# Set up library paths for RocksDB -ENV LD_LIBRARY_PATH="/usr/lib/x86_64-linux-gnu:${LD_LIBRARY_PATH}" -ENV PKG_CONFIG_PATH="/usr/lib/x86_64-linux-gnu/pkgconfig:${PKG_CONFIG_PATH}" - -# Install Python dependencies -RUN pip install --no-cache-dir boto3 pytest requests - -# Set working directory -WORKDIR /workspace - -# Default command -CMD ["/bin/bash"] \ No newline at end of file diff --git a/Makefile b/Makefile index 3ffa482..db2beb6 100644 --- a/Makefile +++ b/Makefile @@ -6,66 +6,6 @@ ODIN := odin BUILD_DIR := build SRC_DIR := . -# Docker configuration for test SDK -TEST_SDK_IMAGE := your-dockerhub-username/odin-python-test-sdk -TEST_SDK_TAG := latest -JORMUN_PORT ?= 8002 - -# Build the test SDK Docker image -.PHONY: build-test-sdk -build-test-sdk: - @echo "Building test SDK Docker image..." - docker build -f Dockerfile_test_sdk -t $(TEST_SDK_IMAGE):$(TEST_SDK_TAG) . - @echo "Test SDK image built successfully" - -# Push the test SDK image to registry -.PHONY: push-test-sdk -push-test-sdk: build-test-sdk - @echo "Pushing test SDK image to registry..." - docker push $(TEST_SDK_IMAGE):$(TEST_SDK_TAG) - @echo "Test SDK image pushed successfully" - -# Pull the test SDK image from registry -.PHONY: pull-test-sdk -pull-test-sdk: - @echo "Pulling test SDK image from registry..." - docker pull $(TEST_SDK_IMAGE):$(TEST_SDK_TAG) - -# Run SDK tests in the consolidated container -.PHONY: test-sdk -test-sdk: - @echo "Running SDK tests..." - docker run --rm \ - --network host \ - -v $(PWD):/workspace \ - -w /workspace \ - -e JORMUN_ENDPOINT=http://localhost:$(JORMUN_PORT) \ - -e AWS_ACCESS_KEY_ID=local \ - -e AWS_SECRET_ACCESS_KEY=local \ - -e AWS_DEFAULT_REGION=us-east-1 \ - $(TEST_SDK_IMAGE):$(TEST_SDK_TAG) \ - sh -c "make build && python tests/sdk/test_sdk.py" - -# Run SDK tests with live rebuild (for development) -.PHONY: test-sdk-dev -test-sdk-dev: - @echo "Running SDK tests with live rebuild..." - docker run --rm -it \ - --network host \ - -v $(PWD):/workspace \ - -w /workspace \ - -e JORMUN_ENDPOINT=http://localhost:$(JORMUN_PORT) \ - -e AWS_ACCESS_KEY_ID=local \ - -e AWS_SECRET_ACCESS_KEY=local \ - -e AWS_DEFAULT_REGION=us-east-1 \ - $(TEST_SDK_IMAGE):$(TEST_SDK_TAG) \ - /bin/bash - -# One-time setup: build and push test SDK image -.PHONY: setup-test-sdk -setup-test-sdk: build-test-sdk push-test-sdk - @echo "Test SDK setup complete" - # C++ shim (WAL replication helpers via RocksDB C++ API) SHIM_DIR := rocksdb_shim SHIM_LIB := $(BUILD_DIR)/libjormun_rocksdb_shim.a @@ -207,36 +147,6 @@ check-deps: @pkg-config --exists rocksdb || (echo "$(RED)✗ RocksDB not found$(NC)" && exit 1) @echo "$(GREEN)✓ All dependencies found$(NC)" -# AWS CLI test commands -aws-test: run & - @sleep 2 - @echo "$(BLUE)Testing with AWS CLI...$(NC)" - @echo "\n$(YELLOW)Creating table...$(NC)" - @aws dynamodb create-table \ - --endpoint-url http://localhost:$(PORT) \ - --table-name TestTable \ - --key-schema AttributeName=pk,KeyType=HASH \ - --attribute-definitions AttributeName=pk,AttributeType=S \ - --billing-mode PAY_PER_REQUEST || true - @echo "\n$(YELLOW)Listing tables...$(NC)" - @aws dynamodb list-tables --endpoint-url http://localhost:$(PORT) - @echo "\n$(YELLOW)Putting item...$(NC)" - @aws dynamodb put-item \ - --endpoint-url http://localhost:$(PORT) \ - --table-name TestTable \ - --item '{"pk":{"S":"test1"},"data":{"S":"hello world"}}' - @echo "\n$(YELLOW)Getting item...$(NC)" - @aws dynamodb get-item \ - --endpoint-url http://localhost:$(PORT) \ - --table-name TestTable \ - --key '{"pk":{"S":"test1"}}' - @echo "\n$(YELLOW)Scanning table...$(NC)" - @aws dynamodb scan \ - --endpoint-url http://localhost:$(PORT) \ - --table-name TestTable - @echo "\n$(GREEN)✓ AWS CLI test complete$(NC)" - - # Development workflow dev: clean build run @@ -261,7 +171,6 @@ help: @echo "" @echo "$(GREEN)Test Commands:$(NC)" @echo " make test - Run unit tests" - @echo " make aws-test - Test with AWS CLI commands" @echo "" @echo "$(GREEN)Utility Commands:$(NC)" @echo " make fmt - Format source code" diff --git a/QUICKSTART.md b/QUICKSTART.md index a15bc50..4770c39 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -404,7 +404,6 @@ brew upgrade odin # macOS ## Next Steps -- Read [ARCHITECTURE.md](ARCHITECTURE.md) for internals - Check [TODO.md](TODO.md) for implementation status - Browse source code in `dynamodb/`, `rocksdb/`, etc. diff --git a/docker-compose-python-sdk-test.yaml b/docker-compose-python-sdk-test.yaml deleted file mode 100644 index 3feeb4e..0000000 --- a/docker-compose-python-sdk-test.yaml +++ /dev/null @@ -1,14 +0,0 @@ -services: - sdk-test: - image: python:3.12-slim - network_mode: host - working_dir: /tests - volumes: - - ./tests/sdk:/tests - environment: - - JORMUN_ENDPOINT=http://localhost:${JORMUN_PORT:-8002} - - AWS_ACCESS_KEY_ID=local - - AWS_SECRET_ACCESS_KEY=local - - AWS_DEFAULT_REGION=us-east-1 - command: > - sh -c "pip install --quiet boto3 && python test_sdk.py" diff --git a/test_sdk.py b/test_sdk.py deleted file mode 100644 index ac87ea2..0000000 --- a/test_sdk.py +++ /dev/null @@ -1,1002 +0,0 @@ -#!/usr/bin/env python3 -""" -JormunDB SDK Compatibility Test Suite - -Runs a comprehensive set of DynamoDB operations via boto3 against a live -JormunDB instance and reports pass/fail for each test. -""" - -import os -import sys -import time -import traceback -from contextlib import contextmanager -from decimal import Decimal - -import boto3 -from botocore.config import Config -from botocore.exceptions import ClientError - -# ============================================================================ -# Configuration -# ============================================================================ - -ENDPOINT = os.environ.get("JORMUN_ENDPOINT", "http://localhost:8002") - -client = boto3.client( - "dynamodb", - endpoint_url=ENDPOINT, - region_name="us-east-1", - aws_access_key_id="local", - aws_secret_access_key="local", - config=Config(retries={"max_attempts": 0}), -) - -resource = boto3.resource( - "dynamodb", - endpoint_url=ENDPOINT, - region_name="us-east-1", - aws_access_key_id="local", - aws_secret_access_key="local", - config=Config(retries={"max_attempts": 0}), -) - -# ============================================================================ -# Test framework -# ============================================================================ - -results = [] - - -def test(name): - """Decorator that registers and runs a test, catching exceptions.""" - def decorator(fn): - def wrapper(): - try: - fn() - results.append(("PASS", name, None)) - except AssertionError as e: - results.append(("FAIL", name, str(e))) - except ClientError as e: - code = e.response["Error"]["Code"] - msg = e.response["Error"]["Message"] - results.append(("FAIL", name, f"{code}: {msg}")) - except Exception as e: - results.append(("FAIL", name, f"{type(e).__name__}: {e}")) - wrapper._test_name = name - wrapper._test_fn = fn - return wrapper - return decorator - - -def cleanup_table(table_name): - """Delete a table, ignoring errors if it doesn't exist.""" - try: - client.delete_table(TableName=table_name) - except ClientError: - pass - - -def assert_eq(actual, expected, msg=""): - label = f" — {msg}" if msg else "" - assert actual == expected, f"expected {expected!r}, got {actual!r}{label}" - - -def assert_in(value, container, msg=""): - label = f" — {msg}" if msg else "" - assert value in container, f"{value!r} not found in {container!r}{label}" - - -# ============================================================================ -# 1. Table Operations -# ============================================================================ - -@test("CreateTable — hash only") -def _(): - cleanup_table("T_HashOnly") - resp = client.create_table( - TableName="T_HashOnly", - KeySchema=[{"AttributeName": "pk", "KeyType": "HASH"}], - AttributeDefinitions=[{"AttributeName": "pk", "AttributeType": "S"}], - BillingMode="PAY_PER_REQUEST", - ) - assert_eq(resp["TableDescription"]["TableName"], "T_HashOnly") - - -@test("CreateTable — hash + range") -def _(): - cleanup_table("T_HashRange") - resp = client.create_table( - TableName="T_HashRange", - KeySchema=[ - {"AttributeName": "pk", "KeyType": "HASH"}, - {"AttributeName": "sk", "KeyType": "RANGE"}, - ], - AttributeDefinitions=[ - {"AttributeName": "pk", "AttributeType": "S"}, - {"AttributeName": "sk", "AttributeType": "S"}, - ], - BillingMode="PAY_PER_REQUEST", - ) - assert_eq(resp["TableDescription"]["TableName"], "T_HashRange") - - -@test("CreateTable — duplicate should fail ResourceInUseException") -def _(): - try: - client.create_table( - TableName="T_HashOnly", - KeySchema=[{"AttributeName": "pk", "KeyType": "HASH"}], - AttributeDefinitions=[{"AttributeName": "pk", "AttributeType": "S"}], - BillingMode="PAY_PER_REQUEST", - ) - assert False, "Should have raised" - except ClientError as e: - assert_eq(e.response["Error"]["Code"], "ResourceInUseException") - - -@test("ListTables — returns created tables") -def _(): - resp = client.list_tables() - names = resp["TableNames"] - assert_in("T_HashOnly", names) - assert_in("T_HashRange", names) - - -@test("DescribeTable — returns key schema") -def _(): - resp = client.describe_table(TableName="T_HashRange") - table = resp["Table"] - assert_eq(table["TableName"], "T_HashRange") - key_types = {ks["KeyType"] for ks in table["KeySchema"]} - assert_in("HASH", key_types) - assert_in("RANGE", key_types) - - -@test("DeleteTable — success") -def _(): - cleanup_table("T_DeleteMe") - client.create_table( - TableName="T_DeleteMe", - KeySchema=[{"AttributeName": "pk", "KeyType": "HASH"}], - AttributeDefinitions=[{"AttributeName": "pk", "AttributeType": "S"}], - BillingMode="PAY_PER_REQUEST", - ) - client.delete_table(TableName="T_DeleteMe") - resp = client.list_tables() - assert "T_DeleteMe" not in resp["TableNames"] - - -@test("DeleteTable — nonexistent should fail ResourceNotFoundException") -def _(): - try: - client.delete_table(TableName="T_NoSuchTable_XYZ") - assert False, "Should have raised" - except ClientError as e: - assert_eq(e.response["Error"]["Code"], "ResourceNotFoundException") - - -# ============================================================================ -# 2. PutItem / GetItem / DeleteItem -# ============================================================================ - -@test("PutItem + GetItem — string key") -def _(): - client.put_item( - TableName="T_HashOnly", - Item={"pk": {"S": "user1"}, "name": {"S": "Alice"}, "age": {"N": "30"}}, - ) - resp = client.get_item(TableName="T_HashOnly", Key={"pk": {"S": "user1"}}) - item = resp["Item"] - assert_eq(item["pk"]["S"], "user1") - assert_eq(item["name"]["S"], "Alice") - assert_eq(item["age"]["N"], "30") - - -@test("PutItem + GetItem — composite key") -def _(): - client.put_item( - TableName="T_HashRange", - Item={"pk": {"S": "order1"}, "sk": {"S": "item#1"}, "qty": {"N": "5"}}, - ) - resp = client.get_item( - TableName="T_HashRange", - Key={"pk": {"S": "order1"}, "sk": {"S": "item#1"}}, - ) - assert_eq(resp["Item"]["qty"]["N"], "5") - - -@test("PutItem — overwrite existing item") -def _(): - client.put_item( - TableName="T_HashOnly", - Item={"pk": {"S": "user1"}, "name": {"S": "Alice Updated"}, "age": {"N": "31"}}, - ) - resp = client.get_item(TableName="T_HashOnly", Key={"pk": {"S": "user1"}}) - assert_eq(resp["Item"]["name"]["S"], "Alice Updated") - assert_eq(resp["Item"]["age"]["N"], "31") - - -@test("GetItem — nonexistent key returns empty") -def _(): - resp = client.get_item(TableName="T_HashOnly", Key={"pk": {"S": "no_such_key"}}) - assert "Item" not in resp - - -@test("DeleteItem — removes item") -def _(): - client.put_item( - TableName="T_HashOnly", - Item={"pk": {"S": "to_delete"}, "data": {"S": "bye"}}, - ) - client.delete_item(TableName="T_HashOnly", Key={"pk": {"S": "to_delete"}}) - resp = client.get_item(TableName="T_HashOnly", Key={"pk": {"S": "to_delete"}}) - assert "Item" not in resp - - -@test("DeleteItem — nonexistent key is no-op (no error)") -def _(): - client.delete_item(TableName="T_HashOnly", Key={"pk": {"S": "never_existed"}}) - - -# ============================================================================ -# 3. Data Types -# ============================================================================ - -@test("PutItem + GetItem — all scalar types (S, N, B, BOOL, NULL)") -def _(): - import base64 - b64 = base64.b64encode(b"binary data").decode() - client.put_item( - TableName="T_HashOnly", - Item={ - "pk": {"S": "types_test"}, - "str_val": {"S": "hello"}, - "num_val": {"N": "42.5"}, - "bin_val": {"B": b64}, - "bool_val": {"BOOL": True}, - "null_val": {"NULL": True}, - }, - ) - resp = client.get_item(TableName="T_HashOnly", Key={"pk": {"S": "types_test"}}) - item = resp["Item"] - assert_eq(item["str_val"]["S"], "hello") - assert_eq(item["num_val"]["N"], "42.5") - assert item["bool_val"]["BOOL"] is True - assert item["null_val"]["NULL"] is True - - -@test("PutItem + GetItem — list and map types") -def _(): - client.put_item( - TableName="T_HashOnly", - Item={ - "pk": {"S": "complex_test"}, - "tags": {"L": [{"S": "a"}, {"S": "b"}, {"N": "3"}]}, - "meta": {"M": {"nested_key": {"S": "nested_val"}, "count": {"N": "10"}}}, - }, - ) - resp = client.get_item(TableName="T_HashOnly", Key={"pk": {"S": "complex_test"}}) - item = resp["Item"] - assert_eq(len(item["tags"]["L"]), 3) - assert_eq(item["meta"]["M"]["nested_key"]["S"], "nested_val") - - -@test("PutItem + GetItem — string set and number set") -def _(): - client.put_item( - TableName="T_HashOnly", - Item={ - "pk": {"S": "set_test"}, - "colors": {"SS": ["red", "blue", "green"]}, - "scores": {"NS": ["100", "200", "300"]}, - }, - ) - resp = client.get_item(TableName="T_HashOnly", Key={"pk": {"S": "set_test"}}) - item = resp["Item"] - assert_eq(set(item["colors"]["SS"]), {"red", "blue", "green"}) - assert_eq(set(item["scores"]["NS"]), {"100", "200", "300"}) - - -# ============================================================================ -# 4. UpdateItem -# ============================================================================ - -@test("UpdateItem — SET new attribute") -def _(): - client.put_item( - TableName="T_HashOnly", - Item={"pk": {"S": "update1"}, "name": {"S": "Bob"}}, - ) - client.update_item( - TableName="T_HashOnly", - Key={"pk": {"S": "update1"}}, - UpdateExpression="SET age = :a", - ExpressionAttributeValues={":a": {"N": "25"}}, - ) - resp = client.get_item(TableName="T_HashOnly", Key={"pk": {"S": "update1"}}) - assert_eq(resp["Item"]["age"]["N"], "25") - assert_eq(resp["Item"]["name"]["S"], "Bob") - - -@test("UpdateItem — SET overwrite existing attribute") -def _(): - client.update_item( - TableName="T_HashOnly", - Key={"pk": {"S": "update1"}}, - UpdateExpression="SET #n = :n", - ExpressionAttributeNames={"#n": "name"}, - ExpressionAttributeValues={":n": {"S": "Bobby"}}, - ) - resp = client.get_item(TableName="T_HashOnly", Key={"pk": {"S": "update1"}}) - assert_eq(resp["Item"]["name"]["S"], "Bobby") - - -@test("UpdateItem — REMOVE attribute") -def _(): - client.update_item( - TableName="T_HashOnly", - Key={"pk": {"S": "update1"}}, - UpdateExpression="REMOVE age", - ) - resp = client.get_item(TableName="T_HashOnly", Key={"pk": {"S": "update1"}}) - assert "age" not in resp["Item"] - - -@test("UpdateItem — SET with ADD arithmetic") -def _(): - client.put_item( - TableName="T_HashOnly", - Item={"pk": {"S": "counter"}, "count": {"N": "10"}}, - ) - client.update_item( - TableName="T_HashOnly", - Key={"pk": {"S": "counter"}}, - UpdateExpression="SET #c = #c + :inc", - ExpressionAttributeNames={"#c": "count"}, - ExpressionAttributeValues={":inc": {"N": "5"}}, - ) - resp = client.get_item(TableName="T_HashOnly", Key={"pk": {"S": "counter"}}) - assert_eq(resp["Item"]["count"]["N"], "15") - - -@test("UpdateItem — ReturnValues ALL_NEW") -def _(): - client.put_item( - TableName="T_HashOnly", - Item={"pk": {"S": "rv_test"}, "x": {"N": "1"}}, - ) - resp = client.update_item( - TableName="T_HashOnly", - Key={"pk": {"S": "rv_test"}}, - UpdateExpression="SET x = :v", - ExpressionAttributeValues={":v": {"N": "99"}}, - ReturnValues="ALL_NEW", - ) - assert_eq(resp["Attributes"]["x"]["N"], "99") - - -@test("UpdateItem — ReturnValues ALL_OLD") -def _(): - resp = client.update_item( - TableName="T_HashOnly", - Key={"pk": {"S": "rv_test"}}, - UpdateExpression="SET x = :v", - ExpressionAttributeValues={":v": {"N": "200"}}, - ReturnValues="ALL_OLD", - ) - assert_eq(resp["Attributes"]["x"]["N"], "99") - - -@test("UpdateItem — upsert (item doesn't exist yet)") -def _(): - cleanup_key("T_HashOnly", {"pk": {"S": "upserted"}}) - client.update_item( - TableName="T_HashOnly", - Key={"pk": {"S": "upserted"}}, - UpdateExpression="SET greeting = :g", - ExpressionAttributeValues={":g": {"S": "hello"}}, - ) - resp = client.get_item(TableName="T_HashOnly", Key={"pk": {"S": "upserted"}}) - assert_eq(resp["Item"]["greeting"]["S"], "hello") - - -# ============================================================================ -# 5. ConditionExpression -# ============================================================================ - -@test("PutItem — ConditionExpression attribute_not_exists succeeds on new item") -def _(): - cleanup_key("T_HashOnly", {"pk": {"S": "cond1"}}) - client.put_item( - TableName="T_HashOnly", - Item={"pk": {"S": "cond1"}, "val": {"S": "first"}}, - ConditionExpression="attribute_not_exists(pk)", - ) - resp = client.get_item(TableName="T_HashOnly", Key={"pk": {"S": "cond1"}}) - assert_eq(resp["Item"]["val"]["S"], "first") - - -@test("PutItem — ConditionExpression attribute_not_exists fails on existing item") -def _(): - try: - client.put_item( - TableName="T_HashOnly", - Item={"pk": {"S": "cond1"}, "val": {"S": "second"}}, - ConditionExpression="attribute_not_exists(pk)", - ) - assert False, "Should have raised ConditionalCheckFailedException" - except ClientError as e: - assert_eq(e.response["Error"]["Code"], "ConditionalCheckFailedException") - - -@test("DeleteItem — ConditionExpression attribute_exists") -def _(): - client.put_item( - TableName="T_HashOnly", - Item={"pk": {"S": "cond_del"}, "status": {"S": "active"}}, - ) - client.delete_item( - TableName="T_HashOnly", - Key={"pk": {"S": "cond_del"}}, - ConditionExpression="attribute_exists(#s)", - ExpressionAttributeNames={"#s": "status"}, - ) - resp = client.get_item(TableName="T_HashOnly", Key={"pk": {"S": "cond_del"}}) - assert "Item" not in resp - - -@test("UpdateItem — ConditionExpression comparison") -def _(): - client.put_item( - TableName="T_HashOnly", - Item={"pk": {"S": "cond_upd"}, "score": {"N": "50"}}, - ) - # Should succeed: score = 50 > 10 - client.update_item( - TableName="T_HashOnly", - Key={"pk": {"S": "cond_upd"}}, - UpdateExpression="SET score = :new", - ConditionExpression="score > :min", - ExpressionAttributeValues={":new": {"N": "100"}, ":min": {"N": "10"}}, - ) - resp = client.get_item(TableName="T_HashOnly", Key={"pk": {"S": "cond_upd"}}) - assert_eq(resp["Item"]["score"]["N"], "100") - - -# ============================================================================ -# 6. Query -# ============================================================================ - -@test("Query — partition key only") -def _(): - # Seed data - for i in range(5): - client.put_item( - TableName="T_HashRange", - Item={"pk": {"S": "q_test"}, "sk": {"S": f"item#{i:03d}"}, "val": {"N": str(i)}}, - ) - resp = client.query( - TableName="T_HashRange", - KeyConditionExpression="pk = :pk", - ExpressionAttributeValues={":pk": {"S": "q_test"}}, - ) - assert_eq(resp["Count"], 5) - - -@test("Query — with sort key begins_with") -def _(): - resp = client.query( - TableName="T_HashRange", - KeyConditionExpression="pk = :pk AND begins_with(sk, :prefix)", - ExpressionAttributeValues={ - ":pk": {"S": "q_test"}, - ":prefix": {"S": "item#00"}, - }, - ) - # item#000 through item#009 — but we only have 000..004 - assert resp["Count"] >= 1 - - -@test("Query — with sort key BETWEEN") -def _(): - resp = client.query( - TableName="T_HashRange", - KeyConditionExpression="pk = :pk AND sk BETWEEN :lo AND :hi", - ExpressionAttributeValues={ - ":pk": {"S": "q_test"}, - ":lo": {"S": "item#001"}, - ":hi": {"S": "item#003"}, - }, - ) - assert_eq(resp["Count"], 3) - - -@test("Query — with Limit") -def _(): - resp = client.query( - TableName="T_HashRange", - KeyConditionExpression="pk = :pk", - ExpressionAttributeValues={":pk": {"S": "q_test"}}, - Limit=2, - ) - assert_eq(resp["Count"], 2) - assert "LastEvaluatedKey" in resp - - -@test("Query — pagination with ExclusiveStartKey") -def _(): - # Get first page - resp1 = client.query( - TableName="T_HashRange", - KeyConditionExpression="pk = :pk", - ExpressionAttributeValues={":pk": {"S": "q_test"}}, - Limit=2, - ) - assert "LastEvaluatedKey" in resp1 - - # Get second page - resp2 = client.query( - TableName="T_HashRange", - KeyConditionExpression="pk = :pk", - ExpressionAttributeValues={":pk": {"S": "q_test"}}, - Limit=2, - ExclusiveStartKey=resp1["LastEvaluatedKey"], - ) - # Second page items should differ from first page - keys1 = {item["sk"]["S"] for item in resp1["Items"]} - keys2 = {item["sk"]["S"] for item in resp2["Items"]} - assert len(keys1 & keys2) == 0, "Pages should not overlap" - - -@test("Query — with FilterExpression") -def _(): - resp = client.query( - TableName="T_HashRange", - KeyConditionExpression="pk = :pk", - FilterExpression="val > :min", - ExpressionAttributeValues={ - ":pk": {"S": "q_test"}, - ":min": {"N": "2"}, - }, - ) - # items 3, 4 have val > 2 - assert_eq(resp["Count"], 2) - assert resp["ScannedCount"] >= resp["Count"] - - -@test("Query — with ProjectionExpression") -def _(): - resp = client.query( - TableName="T_HashRange", - KeyConditionExpression="pk = :pk", - ExpressionAttributeValues={":pk": {"S": "q_test"}}, - ProjectionExpression="pk, sk", - Limit=1, - ) - item = resp["Items"][0] - assert "pk" in item - assert "sk" in item - assert "val" not in item - - -# ============================================================================ -# 7. Scan -# ============================================================================ - -@test("Scan — returns all items") -def _(): - resp = client.scan(TableName="T_HashRange") - assert resp["Count"] >= 5 - - -@test("Scan — with Limit and pagination") -def _(): - resp = client.scan(TableName="T_HashRange", Limit=2) - assert_eq(resp["Count"], 2) - assert "LastEvaluatedKey" in resp - - -@test("Scan — with FilterExpression") -def _(): - resp = client.scan( - TableName="T_HashRange", - FilterExpression="val >= :min", - ExpressionAttributeValues={":min": {"N": "3"}}, - ) - for item in resp["Items"]: - assert int(item["val"]["N"]) >= 3 - - -# ============================================================================ -# 8. BatchWriteItem / BatchGetItem -# ============================================================================ - -@test("BatchWriteItem — put multiple items") -def _(): - cleanup_table("T_Batch") - client.create_table( - TableName="T_Batch", - KeySchema=[{"AttributeName": "pk", "KeyType": "HASH"}], - AttributeDefinitions=[{"AttributeName": "pk", "AttributeType": "S"}], - BillingMode="PAY_PER_REQUEST", - ) - client.batch_write_item( - RequestItems={ - "T_Batch": [ - {"PutRequest": {"Item": {"pk": {"S": f"b{i}"}, "val": {"N": str(i)}}}} - for i in range(10) - ] - } - ) - resp = client.scan(TableName="T_Batch") - assert_eq(resp["Count"], 10) - - -@test("BatchWriteItem — delete items") -def _(): - client.batch_write_item( - RequestItems={ - "T_Batch": [ - {"DeleteRequest": {"Key": {"pk": {"S": f"b{i}"}}}} - for i in range(5) - ] - } - ) - resp = client.scan(TableName="T_Batch") - assert_eq(resp["Count"], 5) - - -@test("BatchWriteItem — exceeds 25 limit should fail") -def _(): - try: - client.batch_write_item( - RequestItems={ - "T_Batch": [ - {"PutRequest": {"Item": {"pk": {"S": f"overflow{i}"}}}} - for i in range(26) - ] - } - ) - assert False, "Should have raised" - except ClientError as e: - assert_eq(e.response["Error"]["Code"], "ValidationException") - - -@test("BatchGetItem — retrieve multiple items") -def _(): - resp = client.batch_get_item( - RequestItems={ - "T_Batch": { - "Keys": [{"pk": {"S": f"b{i}"}} for i in range(5, 10)] - } - } - ) - items = resp["Responses"]["T_Batch"] - assert_eq(len(items), 5) - - -# ============================================================================ -# 9. TransactWriteItems / TransactGetItems -# ============================================================================ - -@test("TransactWriteItems — put multiple items atomically") -def _(): - cleanup_table("T_Txn") - client.create_table( - TableName="T_Txn", - KeySchema=[{"AttributeName": "pk", "KeyType": "HASH"}], - AttributeDefinitions=[{"AttributeName": "pk", "AttributeType": "S"}], - BillingMode="PAY_PER_REQUEST", - ) - client.transact_write_items( - TransactItems=[ - {"Put": {"TableName": "T_Txn", "Item": {"pk": {"S": "tx1"}, "v": {"S": "a"}}}}, - {"Put": {"TableName": "T_Txn", "Item": {"pk": {"S": "tx2"}, "v": {"S": "b"}}}}, - ] - ) - resp = client.scan(TableName="T_Txn") - assert_eq(resp["Count"], 2) - - -@test("TransactWriteItems — with ConditionCheck") -def _(): - # Should succeed: tx1 exists - client.transact_write_items( - TransactItems=[ - { - "ConditionCheck": { - "TableName": "T_Txn", - "Key": {"pk": {"S": "tx1"}}, - "ConditionExpression": "attribute_exists(pk)", - } - }, - {"Put": {"TableName": "T_Txn", "Item": {"pk": {"S": "tx3"}, "v": {"S": "c"}}}}, - ] - ) - resp = client.get_item(TableName="T_Txn", Key={"pk": {"S": "tx3"}}) - assert "Item" in resp - - -@test("TransactWriteItems — condition failure rolls back") -def _(): - try: - client.transact_write_items( - TransactItems=[ - { - "ConditionCheck": { - "TableName": "T_Txn", - "Key": {"pk": {"S": "nonexistent"}}, - "ConditionExpression": "attribute_exists(pk)", - } - }, - {"Put": {"TableName": "T_Txn", "Item": {"pk": {"S": "tx_should_not_exist"}}}}, - ] - ) - assert False, "Should have raised" - except ClientError as e: - assert_in(e.response["Error"]["Code"], - ["TransactionCanceledException", "ConditionalCheckFailedException"]) - # Verify rollback - resp = client.get_item(TableName="T_Txn", Key={"pk": {"S": "tx_should_not_exist"}}) - assert "Item" not in resp - - -@test("TransactGetItems — retrieve multiple items atomically") -def _(): - resp = client.transact_get_items( - TransactItems=[ - {"Get": {"TableName": "T_Txn", "Key": {"pk": {"S": "tx1"}}}}, - {"Get": {"TableName": "T_Txn", "Key": {"pk": {"S": "tx2"}}}}, - ] - ) - items = [r["Item"] for r in resp["Responses"] if "Item" in r] - assert_eq(len(items), 2) - - -# ============================================================================ -# 10. Global Secondary Indexes (GSI) -# ============================================================================ - -@test("CreateTable with GSI") -def _(): - cleanup_table("T_GSI") - resp = client.create_table( - TableName="T_GSI", - KeySchema=[{"AttributeName": "pk", "KeyType": "HASH"}], - AttributeDefinitions=[ - {"AttributeName": "pk", "AttributeType": "S"}, - {"AttributeName": "email", "AttributeType": "S"}, - ], - GlobalSecondaryIndexes=[ - { - "IndexName": "email-index", - "KeySchema": [{"AttributeName": "email", "KeyType": "HASH"}], - "Projection": {"ProjectionType": "ALL"}, - } - ], - BillingMode="PAY_PER_REQUEST", - ) - assert_eq(resp["TableDescription"]["TableName"], "T_GSI") - - -@test("DescribeTable — shows GSI info") -def _(): - resp = client.describe_table(TableName="T_GSI") - table = resp["Table"] - assert "GlobalSecondaryIndexes" in table - gsi = table["GlobalSecondaryIndexes"][0] - assert_eq(gsi["IndexName"], "email-index") - - -@test("GSI — Query by secondary key") -def _(): - # Seed items - for i, email in enumerate(["alice@test.com", "bob@test.com", "alice@test.com"]): - client.put_item( - TableName="T_GSI", - Item={ - "pk": {"S": f"user{i}"}, - "email": {"S": email}, - "name": {"S": f"User{i}"}, - }, - ) - resp = client.query( - TableName="T_GSI", - IndexName="email-index", - KeyConditionExpression="email = :e", - ExpressionAttributeValues={":e": {"S": "alice@test.com"}}, - ) - assert_eq(resp["Count"], 2) - - -@test("GSI — Scan index") -def _(): - resp = client.scan(TableName="T_GSI", IndexName="email-index") - assert resp["Count"] >= 3 - - -@test("GSI — sparse index (item without GSI key not indexed)") -def _(): - client.put_item( - TableName="T_GSI", - Item={"pk": {"S": "no_email_user"}, "name": {"S": "Ghost"}}, - ) - resp = client.scan(TableName="T_GSI", IndexName="email-index") - pks = {item["pk"]["S"] for item in resp["Items"]} - assert "no_email_user" not in pks - - -@test("GSI — updated item reflected in index") -def _(): - client.update_item( - TableName="T_GSI", - Key={"pk": {"S": "user0"}}, - UpdateExpression="SET email = :e", - ExpressionAttributeValues={":e": {"S": "newalice@test.com"}}, - ) - # Old email should no longer match user0 - resp = client.query( - TableName="T_GSI", - IndexName="email-index", - KeyConditionExpression="email = :e", - ExpressionAttributeValues={":e": {"S": "alice@test.com"}}, - ) - pks = {item["pk"]["S"] for item in resp["Items"]} - assert "user0" not in pks - - # New email should match user0 - resp2 = client.query( - TableName="T_GSI", - IndexName="email-index", - KeyConditionExpression="email = :e", - ExpressionAttributeValues={":e": {"S": "newalice@test.com"}}, - ) - pks2 = {item["pk"]["S"] for item in resp2["Items"]} - assert_in("user0", pks2) - - -@test("GSI — deleted item removed from index") -def _(): - client.delete_item(TableName="T_GSI", Key={"pk": {"S": "user1"}}) - resp = client.query( - TableName="T_GSI", - IndexName="email-index", - KeyConditionExpression="email = :e", - ExpressionAttributeValues={":e": {"S": "bob@test.com"}}, - ) - assert_eq(resp["Count"], 0) - - -# ============================================================================ -# 11. Edge Cases -# ============================================================================ - -@test("PutItem — empty string attribute") -def _(): - client.put_item( - TableName="T_HashOnly", - Item={"pk": {"S": "empty_str"}, "val": {"S": ""}}, - ) - resp = client.get_item(TableName="T_HashOnly", Key={"pk": {"S": "empty_str"}}) - assert_eq(resp["Item"]["val"]["S"], "") - - -@test("PutItem — large item (~300KB)") -def _(): - big_val = "x" * 300_000 - client.put_item( - TableName="T_HashOnly", - Item={"pk": {"S": "big_item"}, "data": {"S": big_val}}, - ) - resp = client.get_item(TableName="T_HashOnly", Key={"pk": {"S": "big_item"}}) - assert_eq(len(resp["Item"]["data"]["S"]), 300_000) - - -@test("PutItem — numeric key") -def _(): - cleanup_table("T_NumKey") - client.create_table( - TableName="T_NumKey", - KeySchema=[{"AttributeName": "id", "KeyType": "HASH"}], - AttributeDefinitions=[{"AttributeName": "id", "AttributeType": "N"}], - BillingMode="PAY_PER_REQUEST", - ) - client.put_item( - TableName="T_NumKey", - Item={"id": {"N": "42"}, "data": {"S": "numeric key"}}, - ) - resp = client.get_item(TableName="T_NumKey", Key={"id": {"N": "42"}}) - assert_eq(resp["Item"]["data"]["S"], "numeric key") - - -@test("Query — nonexistent partition returns 0 items") -def _(): - resp = client.query( - TableName="T_HashRange", - KeyConditionExpression="pk = :pk", - ExpressionAttributeValues={":pk": {"S": "no_such_partition"}}, - ) - assert_eq(resp["Count"], 0) - - -# ============================================================================ -# Helpers -# ============================================================================ - -def cleanup_key(table_name, key): - try: - client.delete_item(TableName=table_name, Key=key) - except ClientError: - pass - - -# ============================================================================ -# Runner -# ============================================================================ - -def main(): - # Wait for JormunDB to be reachable - print(f"\n{'='*60}") - print(f" JormunDB Python SDK Test Suite") - print(f" Endpoint: {ENDPOINT}") - print(f"{'='*60}\n") - - print("Waiting for JormunDB...", end="", flush=True) - for attempt in range(30): - try: - client.list_tables() - print(" connected!\n") - break - except Exception: - print(".", end="", flush=True) - time.sleep(1) - else: - print("\n\nERROR: Could not connect to JormunDB at", ENDPOINT) - sys.exit(1) - - # Collect all test functions from this module - tests = [ - obj for obj in globals().values() - if callable(obj) and hasattr(obj, "_test_name") - ] - - # Run tests in definition order - for t in tests: - sys.stdout.write(f" {t._test_name} ... ") - sys.stdout.flush() - t() - status, name, err = results[-1] - if status == "PASS": - print("\033[32mPASS\033[0m") - else: - print(f"\033[31mFAIL\033[0m {err}") - - # Summary - passed = sum(1 for s, _, _ in results if s == "PASS") - failed = sum(1 for s, _, _ in results if s == "FAIL") - total = len(results) - - print(f"\n{'='*60}") - if failed == 0: - print(f" \033[32mAll {total} tests passed!\033[0m") - else: - print(f" \033[32m{passed} passed\033[0m, \033[31m{failed} failed\033[0m out of {total}") - print() - for status, name, err in results: - if status == "FAIL": - print(f" \033[31m✗ {name}\033[0m") - print(f" {err}") - print(f"{'='*60}\n") - - # Cleanup test tables - print("Cleaning up test tables...") - for tbl in ["T_HashOnly", "T_HashRange", "T_Batch", "T_Txn", "T_GSI", "T_NumKey", "T_DeleteMe"]: - cleanup_table(tbl) - print("Done.\n") - - sys.exit(1 if failed else 0) - - -if __name__ == "__main__": - main() \ No newline at end of file