diff --git a/demos/__init__.py b/demos/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/demos/demo_dynamic_nested_handler.py b/demos/demo_dynamic_nested_handler.py index f7f3d21..27f6e95 100644 --- a/demos/demo_dynamic_nested_handler.py +++ b/demos/demo_dynamic_nested_handler.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -Demo for DynamicNestedHandler comprehensive functionality. +Demo for DataFrameHandler comprehensive functionality. This demonstrates a truly dynamic handler that can work with ANY nested DataFrame structure. Key features: @@ -16,14 +16,14 @@ create_extended_nested_dataframe, create_deeply_nested_dataframe, ) -from leanframe.core.frame import DynamicNestedHandler +from leanframe.core.frame import DataFrameHandler def demo_basic_usage(): - """Demo basic DynamicNestedHandler usage and structure inspection.""" + """Demo basic DataFrameHandler usage and structure inspection.""" print("=== Basic Usage Demo ===") df = create_simple_nested_dataframe(5) - handler = DynamicNestedHandler(df) + handler = DataFrameHandler(df) print( f"Original columns: {len(handler.original_columns)} - {handler.original_columns}" @@ -60,10 +60,10 @@ def demo_basic_usage(): def demo_data_access(): - """Demo data access patterns of DynamicNestedHandler.""" + """Demo data access patterns of DataFrameHandler.""" print("\n=== Data Access Demo ===") df = create_simple_nested_dataframe(3) - handler = DynamicNestedHandler(df) + handler = DataFrameHandler(df) # Column-wise access names = handler.get_column("person_name") @@ -88,15 +88,15 @@ def demo_data_access(): def demo_filtering(): - """Demo filtering functionality of DynamicNestedHandler.""" + """Demo filtering functionality of DataFrameHandler.""" print("\n=== Filtering Demo ===") df = create_simple_nested_dataframe(5) - handler = DynamicNestedHandler(df) + handler = DataFrameHandler(df) print(f"Original handler has {len(handler)} records") # Filter by age - returns a new handler - filtered_handler = handler.filter_by("person_age", 30) + filtered_handler = handler.filter_by(person_age=30) print(f"Filtered handler (age=30) has {len(filtered_handler)} records") # Show the filtered results @@ -109,12 +109,12 @@ def demo_filtering(): def demo_different_structures(): - """Demo DynamicNestedHandler with different nested structures.""" + """Demo DataFrameHandler with different nested structures.""" print("\n=== Different Structures Demo ===") # Extended structure with address extended_df = create_extended_nested_dataframe(2) - extended_handler = DynamicNestedHandler(extended_df) + extended_handler = DataFrameHandler(extended_df) expected_extended_columns = [ "id", @@ -143,10 +143,10 @@ def demo_different_structures(): def demo_deep_nesting(): - """Demo DynamicNestedHandler with deeply nested structures.""" + """Demo DataFrameHandler with deeply nested structures.""" print("\n=== Deep Nesting Demo ===") deep_df = create_deeply_nested_dataframe() - deep_handler = DynamicNestedHandler(deep_df) + deep_handler = DataFrameHandler(deep_df) print(f"Deep nested structure has {len(deep_handler.columns)} columns") print(f"Columns: {deep_handler.columns}") @@ -171,10 +171,10 @@ def demo_deep_nesting(): def demo_handler_capabilities(): - """Demo general capabilities and edge cases of DynamicNestedHandler.""" + """Demo general capabilities and edge cases of DataFrameHandler.""" print("\n=== Handler Capabilities Demo ===") df = create_simple_nested_dataframe(2) - handler = DynamicNestedHandler(df) + handler = DataFrameHandler(df) print(f"Handler length: {len(handler)}") @@ -197,7 +197,7 @@ def demo_handler_capabilities(): def main(): """Run all demos.""" - print("πŸš€ DynamicNestedHandler Comprehensive Demo") + print("πŸš€ DataFrameHandler Comprehensive Demo") print("=" * 50) try: @@ -210,7 +210,7 @@ def main(): print("\n" + "=" * 50) print("βœ… All demos completed successfully!") - print("🎯 DynamicNestedHandler can handle arbitrary nested structures!") + print("🎯 DataFrameHandler can handle arbitrary nested structures!") except Exception as e: print(f"\n❌ Demo failed with error: {e}") diff --git a/demos/demo_flexible_joins.py b/demos/demo_flexible_joins.py index e472d8d..74c599c 100644 --- a/demos/demo_flexible_joins.py +++ b/demos/demo_flexible_joins.py @@ -39,6 +39,7 @@ import sys from pathlib import Path + sys.path.insert(0, str(Path(__file__).parent.parent)) import ibis @@ -48,7 +49,7 @@ from leanframe.core.nested_handler import NestedHandler from demos.utils.create_nested_data import ( create_customers_for_join, - create_orders_for_join + create_orders_for_join, ) @@ -56,68 +57,70 @@ def main(): print("=" * 70) print("πŸš€ Flexible SQL-like Joins with NestedHandler") print("=" * 70) - + # Setup backend = ibis.duckdb.connect() session = leanframe.Session(backend=backend) nested = NestedHandler() - + # =================================================================== # STEP 1: Prepare Data # =================================================================== print("\n" + "=" * 70) print("STEP 1: Prepare Test Data") print("=" * 70) - + # Create nested DataFrames customers_df = create_customers_for_join() orders_df = create_orders_for_join() - + # Add third table - products - products_pd = pd.DataFrame({ - 'product_id': [1, 2, 3], - 'name': ['Widget', 'Gadget', 'Doohickey'], - 'category': ['Electronics', 'Electronics', 'Hardware'], - 'price': [29.99, 149.99, 9.99] - }) + products_pd = pd.DataFrame( + { + "product_id": [1, 2, 3], + "name": ["Widget", "Gadget", "Doohickey"], + "category": ["Electronics", "Electronics", "Hardware"], + "price": [29.99, 149.99, 9.99], + } + ) products_df = session.DataFrame(products_pd) - + # Add to NestedHandler nested.add("customers", session.DataFrame(customers_df.to_pandas())) nested.add("orders", session.DataFrame(orders_df.to_pandas())) nested.add("products", products_df) - + print("\nβœ… Added 3 tables:") print(" - customers: nested profile.contact.email") print(" - orders: nested shipping.recipient.email") print(" - products: flat structure") - + # =================================================================== # STEP 2: Simple Two-Table Join # =================================================================== print("\n" + "=" * 70) print("STEP 2: Simple Two-Table Join (NEW Approach)") print("=" * 70) - + print("\nπŸ“‹ OLD way (limited, deprecated):") print(" result = nested.join_on_both_nested(...)") print(" ❌ Only 2 tables") print(" ❌ Limited join types") print(" ❌ No WHERE, HAVING, etc.") - + print("\nπŸ“‹ NEW way (full SQL power):") print(" 1. Prepare: Extract nested fields") print(" 2. Join: Use Ibis directly") print(" 3. Query: Add WHERE, GROUP BY, etc.") - + # Step 1: Prepare - extract nested fields print("\n1️⃣ Prepare DataFrames (extract nested fields):") customers_flat = nested.prepare("customers") orders_flat = nested.prepare("orders") - + print(f" Customers: {len(customers_flat.columns)} columns") print(f" Orders: {len(orders_flat.columns)} columns") - + # Step 2: Join using Ibis print("\n2️⃣ Join using Ibis (full SQL flexibility):") # Note: After prepare(), nested fields are flattened with underscores @@ -126,142 +129,150 @@ def main(): joined = customers_flat._data.join( orders_flat._data, predicates=[ - customers_flat._data.profile_contact_email == - orders_flat._data.shipping_recipient_email + customers_flat._data.profile_contact_email + == orders_flat._data.shipping_recipient_email ], - how="inner" + how="inner", ) - + result = DataFrame(joined) - print(f" βœ… Joined: {len(result.columns)} columns, {joined.count().execute()} rows") - + print( + f" βœ… Joined: {len(result.columns)} columns, {joined.count().execute()} rows" + ) + # Step 3: Add WHERE clause print("\n3️⃣ Add WHERE clause (filter results):") filtered = joined.filter(joined.amount > 100) # type: ignore result_filtered = DataFrame(filtered) # noqa print(f" βœ… Filtered (amount > 100): {filtered.count().execute()} rows") - + # =================================================================== # STEP 3: Three-Table Join # =================================================================== print("\n" + "=" * 70) print("STEP 3: Three-Table Join (IMPOSSIBLE with old methods!)") print("=" * 70) - + print("\n🎯 Goal: customers β‹ˆ orders β‹ˆ products") print(" Old methods: ❌ Can't do this!") print(" New approach: βœ… Easy!") - + # Modify orders to have product_id orders_with_products_pd = orders_flat.to_pandas() - orders_with_products_pd['product_id'] = [1, 2, 1, 3, 2] # Match order IDs + orders_with_products_pd["product_id"] = [1, 2, 1, 3, 2] # Match order IDs orders_with_products = session.DataFrame(orders_with_products_pd) - + print("\n1️⃣ First join: customers β‹ˆ orders") step1 = customers_flat._data.join( orders_with_products._data, predicates=[ - customers_flat._data.profile_contact_email == - orders_with_products._data.shipping_recipient_email + customers_flat._data.profile_contact_email + == orders_with_products._data.shipping_recipient_email ], - how="inner" + how="inner", ) - + print("\n2️⃣ Second join: (customers β‹ˆ orders) β‹ˆ products") step2 = step1.join( products_df._data, predicates=[step1.product_id == products_df._data.product_id], - how="inner" + how="inner", ) - + three_table_result = DataFrame(step2) print(f" βœ… Three-table join: {len(three_table_result.columns)} columns") print(f" βœ… Result: {step2.count().execute()} rows") - + # =================================================================== # STEP 4: Complex Query with GROUP BY # =================================================================== print("\n" + "=" * 70) print("STEP 4: Complex Query (GROUP BY + Aggregation)") print("=" * 70) - + print("\n🎯 Calculate total sales per customer") - + # Group and aggregate grouped = step2.group_by("customer_id").aggregate( total_amount=step2.amount.sum(), # type: ignore order_count=step2.order_id.count(), # type: ignore - avg_price=step2.price.mean() # type: ignore + avg_price=step2.price.mean(), # type: ignore ) - + grouped_result = DataFrame(grouped) print(f" βœ… Grouped by customer: {grouped.count().execute()} customers") print("\n Sample results:") sample = grouped_result.to_pandas().head(3) for _, row in sample.iterrows(): - print(f" Customer {row['customer_id']}: " - f"{row['order_count']} orders, " - f"${row['total_amount']:.2f} total, " - f"${row['avg_price']:.2f} avg") - + print( + f" Customer {row['customer_id']}: " + f"{row['order_count']} orders, " + f"${row['total_amount']:.2f} total, " + f"${row['avg_price']:.2f} avg" + ) + # =================================================================== # STEP 5: Different Join Types # =================================================================== print("\n" + "=" * 70) print("STEP 5: Different Join Types (ALL supported!)") print("=" * 70) - + print("\nβœ… Supported join types:") join_types = ["inner", "left", "right", "outer", "cross", "semi", "anti"] for jt in join_types: print(f" β€’ {jt}") - + print("\nπŸ“ Example: LEFT JOIN (keep all customers)") left_join = customers_flat._data.join( orders_flat._data, predicates=[ - customers_flat._data.profile_contact_email == - orders_flat._data.shipping_recipient_email + customers_flat._data.profile_contact_email + == orders_flat._data.shipping_recipient_email ], - how="left" + how="left", ) left_result = DataFrame(left_join) - print(f" βœ… LEFT JOIN: {left_result._data.count().execute()} rows (includes customers without orders)") - + print( + f" βœ… LEFT JOIN: {left_result._data.count().execute()} rows (includes customers without orders)" + ) + # =================================================================== # STEP 6: Window Functions # =================================================================== print("\n" + "=" * 70) print("STEP 6: Window Functions (BigQuery/SQL feature)") print("=" * 70) - + print("\n🎯 Add row numbers within each customer's orders") - + # Use Ibis window functions window = ibis.window(group_by=step2.customer_id, order_by=step2.amount.desc()) with_rank = step2.select( step2.customer_id, step2.order_id, step2.amount, - order_rank=ibis.row_number().over(window) + order_rank=ibis.row_number().over(window), ) - + rank_result = DataFrame(with_rank) print(f" βœ… Added ranking: {rank_result._data.count().execute()} rows") print("\n Sample with rankings:") sample = rank_result.to_pandas().head(5) for _, row in sample.iterrows(): - print(f" Customer {row['customer_id']}, " - f"Order {row['order_id']}: " - f"${row['amount']:.2f} (rank #{row['order_rank']})") - + print( + f" Customer {row['customer_id']}, " + f"Order {row['order_id']}: " + f"${row['amount']:.2f} (rank #{row['order_rank']})" + ) + # =================================================================== # Summary # =================================================================== print("\n" + "=" * 70) print("βœ… Summary: Why This Architecture is Better") print("=" * 70) - + print("\n🎯 Key Advantages:") print(" 1. βœ… Join ANY number of tables (not just 2)") print(" 2. βœ… ALL join types: inner, left, right, outer, cross, semi, anti") @@ -273,7 +284,7 @@ def main(): print(" 8. βœ… Window functions (ROW_NUMBER, RANK, LAG, LEAD, etc.)") print(" 9. βœ… UNION, INTERSECT, EXCEPT") print(" 10. βœ… Everything BigQuery/SQL supports!") - + print("\nπŸ“ Design Pattern:") print(" β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”") print(" β”‚ NestedHandler: Prepare nested data β”‚") @@ -285,16 +296,16 @@ def main(): print(" β”‚ Ibis/Backend: Handle ALL SQL operations β”‚") print(" β”‚ (Joins, WHERE, GROUP BY, windows, etc.) β”‚") print(" β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜") - + print("\nπŸ”§ Simple Workflow:") print(" 1. handler.prepare('table_name') # Extract nested β†’ flat") print(" 2. Use Ibis operations directly # Full SQL power") print(" 3. Result is ready for BigQuery # Or any backend") - + print("\nπŸ’‘ Future: handler.join() convenience method") print(" Will wrap this pattern for common cases") print(" But you ALWAYS have direct Ibis access for complex queries!") - + backend.disconnect() print("\nπŸ”Œ Disconnected from database") diff --git a/demos/demo_indexing_with_nested.py b/demos/demo_indexing_with_nested.py new file mode 100644 index 0000000..561250b --- /dev/null +++ b/demos/demo_indexing_with_nested.py @@ -0,0 +1,461 @@ +""" +Example: Using Indexing with Nested Data in Leanframe + +This demo shows how to use the new indexing features with nested columns. +It covers: +- Setting up a DataFrame with nested structures +- Using DataFrameHandler to extract nested fields +- Applying indexing for deterministic ordering +- Combining indexing with joins via NestedHandler +""" + +import ibis +import pandas as pd +from datetime import datetime + +# Import leanframe components +from leanframe.core.frame import DataFrame, DataFrameHandler +from leanframe.core.nested_handler import NestedHandler + + +def create_sample_nested_data(): + """Create sample data with nested structures for testing.""" + + # Sample customer data with nested profiles + customers_data = { + "customer_id": [1001, 1002, 1003, 1004, 1005], + "profile": [ + {"name": "Alice Johnson", "age": 34, "email": "alice@example.com"}, + {"name": "Bob Smith", "age": 28, "email": "bob@example.com"}, + {"name": "Carol Davis", "age": 45, "email": "carol@example.com"}, + {"name": "David Wilson", "age": 31, "email": "david@example.com"}, + {"name": "Eve Martinez", "age": 39, "email": "eve@example.com"}, + ], + "registration_date": [ + datetime(2024, 1, 15), + datetime(2024, 2, 20), + datetime(2023, 11, 5), + datetime(2024, 3, 10), + datetime(2023, 12, 18), + ], + } + + # Sample order data + orders_data = { + "order_id": [5001, 5002, 5003, 5004, 5005, 5006], + "customer_id": [1001, 1001, 1002, 1003, 1004, 1005], + "amount": [299.99, 149.50, 599.00, 89.99, 450.00, 199.99], + "order_date": [ + datetime(2024, 3, 1), + datetime(2024, 3, 15), + datetime(2024, 3, 5), + datetime(2024, 3, 20), + datetime(2024, 3, 12), + datetime(2024, 3, 8), + ], + "status": [ + "completed", + "completed", + "pending", + "completed", + "shipped", + "completed", + ], + } + + return customers_data, orders_data + + +def demo_basic_indexing(): + """Demo 1: Basic indexing without nested data.""" + print("\n" + "=" * 70) + print("DEMO 1: Basic Indexing") + print("=" * 70) + + # Create simple ibis table + data = { + "id": [1, 2, 3, 4, 5], + "value": [10, 20, 30, 40, 50], + "timestamp": pd.date_range("2024-01-01", periods=5), + } + + # Create leanframe DataFrame + ibis_table = ibis.memtable(data) + df = DataFrame(ibis_table) + + print(f"\nOriginal DataFrame shape: {len(df.columns)} columns") + print(f"Columns: {df.columns.tolist()}") + + # Set index on timestamp + print("\nπŸ“ Setting index on 'timestamp' (ascending)...") + df_indexed = df.set_index("timestamp", ascending=True) + print(f"Index: {df_indexed.index}") + + # Use iloc + print("\nπŸ”’ Using .iloc for position-based access:") + print("\n First 2 rows (df.iloc[0:2]):") + first_2 = df_indexed.iloc[0:2] + print(first_2.to_pandas()) + + # Use head/tail + print("\nπŸ“Š Using .head() and .tail():") + print("\n First 3 rows (df.head(3)):") + print(df_indexed.head(3).to_pandas()) + + print("\n Last 2 rows (df.tail(2)):") + print(df_indexed.tail(2).to_pandas()) + + # Use loc + print("\n🏷️ Setting index on 'id' for .loc access:") + df_by_id = df.set_index("id") + print("\n Get row where id=3 (df.loc[3]):") + print(df_by_id.loc[3].to_pandas()) + + print("\n Get range id=2:4 (df.loc[2:4]):") + print(df_by_id.loc[2:4].to_pandas()) + + +def demo_nested_data_with_indexing(): + """Demo 2: Indexing with nested data extraction.""" + print("\n" + "=" * 70) + print("DEMO 2: Nested Data + Indexing") + print("=" * 70) + + customers_data, _ = create_sample_nested_data() + + # Create leanframe DataFrame with nested data + ibis_table = ibis.memtable(customers_data) + customers_df = DataFrame(ibis_table) + + print("\nOriginal nested DataFrame:") + print(f"Columns: {customers_df.columns.tolist()}") + + # Create handler to analyze nested structure + print("\nπŸ” Creating DataFrameHandler to analyze nested structure...") + handler = DataFrameHandler(customers_df) + handler.show_structure() + + # Extract nested fields + print("\nπŸ“€ Extracting nested fields...") + flat_df = handler.extract_nested_fields(verbose=False) + print(f"Flattened columns: {flat_df.columns.tolist()}") + + # Now apply indexing to the flattened DataFrame + print("\nπŸ“ Setting index on 'profile_age' (descending - oldest first)...") + by_age = flat_df.set_index("profile_age", ascending=False) + + print("\nπŸ‘΄ Oldest 3 customers (by_age.head(3)):") + print( + by_age.head(3).to_pandas()[ + ["customer_id", "profile_name", "profile_age", "profile_email"] + ] + ) + + print("\nπŸ‘Ά Youngest 2 customers (by_age.tail(2)):") + print( + by_age.tail(2).to_pandas()[ + ["customer_id", "profile_name", "profile_age", "profile_email"] + ] + ) + + # Index by registration date + print("\nπŸ“ Setting index on 'registration_date' (newest first)...") + by_date = flat_df.set_index("registration_date", ascending=False) + + print("\nπŸ†• Most recent 3 registrations (by_date.iloc[0:3]):") + print( + by_date.iloc[0:3].to_pandas()[ + ["customer_id", "profile_name", "registration_date"] + ] + ) + + # Use .loc on customer_id + print("\nπŸ“ Setting index on 'customer_id' for .loc access...") + by_id = flat_df.set_index("customer_id") + + print("\n🎯 Get specific customers (by_id.loc[[1001, 1003]]):") + print( + by_id.loc[[1001, 1003]].to_pandas()[ + ["customer_id", "profile_name", "profile_email"] + ] + ) + + +def demo_joins_with_indexing(): + """Demo 3: Combining NestedHandler joins with indexing.""" + print("\n" + "=" * 70) + print("DEMO 3: Joins + Indexing") + print("=" * 70) + + customers_data, orders_data = create_sample_nested_data() + + # Create DataFrames + customers_ibis = ibis.memtable(customers_data) + orders_ibis = ibis.memtable(orders_data) + + customers_df = DataFrame(customers_ibis) + orders_df = DataFrame(orders_ibis) + + # Setup NestedHandler + print("\nπŸ”§ Setting up NestedHandler...") + handler = NestedHandler() + handler.add("customers", customers_df) + handler.add("orders", orders_df) + + # Prepare customers (extract nested fields) + print("\nπŸ“€ Preparing customers DataFrame (extracting nested fields)...") + customers_prep = handler.prepare("customers", verbose=False) + + # Add prepared version back + handler.add("customers_flat", customers_prep) + + # Perform join + print("\nπŸ”— Joining customers with orders...") + joined = handler.join( + tables={"c": "customers_flat", "o": "orders"}, + on=[("c", "customer_id", "o", "customer_id")], + how="inner", + ) + + print(f"Joined DataFrame columns: {len(joined.columns)}") + + # Apply indexing to joined result + print("\nπŸ“ Setting index on 'order_date' (most recent first)...") + by_date = joined.set_index("order_date", ascending=False) + + print("\nπŸ†• Most recent 3 orders (by_date.head(3)):") + result = by_date.head(3).to_pandas() + print(result[["order_id", "profile_name", "amount", "order_date", "status"]]) + + # Index by amount + print("\nπŸ“ Setting index on 'amount' (highest first)...") + by_amount = joined.set_index("amount", ascending=False) + + print("\nπŸ’° Top 3 highest value orders (by_amount.iloc[0:3]):") + result = by_amount.iloc[0:3].to_pandas() + print(result[["order_id", "profile_name", "profile_email", "amount", "order_date"]]) + + # Use .loc to filter by customer_id + print("\nπŸ“ Setting index on 'customer_id' for .loc filtering...") + by_customer = joined.set_index("customer_id") + + print("\nπŸ‘€ All orders for customer 1001 (by_customer.loc[1001]):") + result = by_customer.loc[1001].to_pandas() + print(result[["order_id", "profile_name", "amount", "order_date"]]) + + +def demo_chaining_operations(): + """Demo 4: Chaining indexing with other operations.""" + print("\n" + "=" * 70) + print("DEMO 4: Chaining Operations") + print("=" * 70) + + _, orders_data = create_sample_nested_data() + + orders_ibis = ibis.memtable(orders_data) + orders_df = DataFrame(orders_ibis) + + print("\nπŸ“Š Original orders:") + print(orders_df.to_pandas()) + + # Chain: filter -> set index -> slice + print("\nπŸ”— Chaining: filter completed orders, order by date, get top 2...") + + # Filter completed orders (using Ibis directly) + completed = orders_df._data.filter(orders_df._data.status == "completed") + completed_df = DataFrame(completed) + + # Set index and slice + by_date = completed_df.set_index("order_date", ascending=False) + recent_completed = by_date.iloc[0:2] + + print("\nβœ… Most recent 2 completed orders:") + print(recent_completed.to_pandas()) + + # Chain: order by amount, get top 3, then filter by customer + print("\nπŸ”— Chaining: order by amount (desc), get top 3...") + by_amount = orders_df.set_index("amount", ascending=False) + top_3_value = by_amount.iloc[0:3] + + print("\nπŸ’Ž Top 3 highest value orders:") + print(top_3_value.to_pandas()) + + +def demo_error_cases(): + """Demo 5: Common error cases and how to handle them.""" + print("\n" + "=" * 70) + print("DEMO 5: Error Handling") + print("=" * 70) + + data = {"id": [1, 2, 3], "value": [10, 20, 30]} + ibis_table = ibis.memtable(data) + df = DataFrame(ibis_table) + + # Error 1: Using .iloc without setting index + print("\n❌ Error 1: Using .iloc without index...") + try: + df.iloc[0] + except ValueError as e: + print(f"Caught expected error: {e}") + + # Error 2: Using .loc without setting index + print("\n❌ Error 2: Using .loc without index...") + try: + df.loc[1] + except ValueError as e: + print(f"Caught expected error: {e}") + + # Error 3: Setting index on non-existent column + print("\n❌ Error 3: Setting index on non-existent column...") + try: + df.set_index("nonexistent") + except KeyError as e: + print(f"Caught expected error: {e}") + + # Success: Proper usage + print("\nβœ… Proper usage: set index first...") + df_indexed = df.set_index("id") + print(f"Index set: {df_indexed.index}") + print("Now .iloc and .loc work correctly!") + result = df_indexed.loc[2] + print(result.to_pandas()) + + +def demo_multi_column_ordering(): + """Demo 6: Multi-column composite ordering (like SQL ORDER BY col1, col2).""" + print("\n" + "=" * 70) + print("DEMO 6: Multi-Column Ordering") + print("=" * 70) + + # Create sample data with priority and timestamp + task_data = { + "task_id": [101, 102, 103, 104, 105, 106, 107, 108], + "priority": [1, 1, 2, 2, 3, 3, 1, 2], + "timestamp": [ + datetime(2024, 3, 1, 10, 0), + datetime(2024, 3, 1, 9, 0), + datetime(2024, 3, 1, 11, 0), + datetime(2024, 3, 1, 8, 0), + datetime(2024, 3, 1, 12, 0), + datetime(2024, 3, 1, 7, 0), + datetime(2024, 3, 1, 13, 0), + datetime(2024, 3, 1, 14, 0), + ], + "description": [ + "Task A", + "Task B", + "Task C", + "Task D", + "Task E", + "Task F", + "Task G", + "Task H", + ], + } + + ibis_table = ibis.memtable(task_data) + df = DataFrame(ibis_table) + + print("\nOriginal data (unordered):") + print(df.to_pandas()[["task_id", "priority", "timestamp", "description"]]) + + # Single-column ordering + print("\nπŸ“ Single-column index on 'priority' (ascending):") + by_priority = df.set_index("priority") + print(by_priority.to_pandas()[["task_id", "priority", "timestamp", "description"]]) + print("Note: Within each priority level, order is not deterministic") + + # Multi-column ordering - priority ASC, timestamp ASC + print("\nπŸ“ Multi-column index: ['priority', 'timestamp'] (both ascending):") + by_priority_time = df.set_index(["priority", "timestamp"], ascending=True) + print( + by_priority_time.to_pandas()[ + ["task_id", "priority", "timestamp", "description"] + ] + ) + print( + "Result: Ordered by priority, then by earliest timestamp within each priority" + ) + + # Multi-column with different directions + print("\nπŸ“ Multi-column index: ['priority', 'timestamp'] with [DESC, ASC]:") + by_priority_desc = df.set_index(["priority", "timestamp"], ascending=[False, True]) + print( + by_priority_desc.to_pandas()[ + ["task_id", "priority", "timestamp", "description"] + ] + ) + print("Result: Highest priority first, then earliest timestamp (priority queue)") + + # Use with iloc + print("\n🎯 Getting top 3 tasks with multi-column ordering:") + print(" (Highest priority first, earliest timestamp breaks ties)") + top_3 = by_priority_desc.iloc[0:3] + print(top_3.to_pandas()[["task_id", "priority", "timestamp", "description"]]) + + # Example with nested data + print("\nπŸ“ Multi-column ordering with nested fields:") + customers_data, _ = create_sample_nested_data() + customers_df = DataFrame(ibis.memtable(customers_data)) + handler = DataFrameHandler(customers_df) + flat_df = handler.extract_nested_fields(verbose=False) + + # Order by age DESC, then registration_date ASC + by_age_date = flat_df.set_index( + ["profile_age", "registration_date"], ascending=[False, True] + ) + print( + "\n Ordered by age DESC (nested field), registration_date ASC (regular field):" + ) + print( + by_age_date.to_pandas()[ + ["customer_id", "profile_name", "profile_age", "registration_date"] + ] + ) + print("\n SQL equivalent: ORDER BY profile_age DESC, registration_date ASC") + print("\n This demonstrates ordering across different nesting levels:") + print(" - 'profile_age' comes from nested 'profile' struct") + print(" - 'registration_date' is a regular top-level column") + + # More complex example: order by regular column, then multiple nested fields + print("\nπŸ“ Complex multi-level ordering:") + print( + " Order by registration_date DESC (regular), then profile_age ASC (nested), then profile_name ASC (nested):" + ) + complex_order = flat_df.set_index( + ["registration_date", "profile_age", "profile_name"], + ascending=[False, True, True], + ) + result = complex_order.to_pandas()[ + ["customer_id", "profile_name", "profile_age", "registration_date"] + ] + print(result) + print("\n This shows:") + print(" - Primary sort: newest registrations first (regular column)") + print(" - Secondary sort: youngest first within same date (nested field)") + print(" - Tertiary sort: alphabetical by name for same age (nested field)") + + +if __name__ == "__main__": + print("\n" + "πŸš€ LEANFRAME INDEXING EXAMPLES" + "\n") + print("This demo shows indexing features with nested data support") + + # Run all demos + demo_basic_indexing() + demo_nested_data_with_indexing() + demo_joins_with_indexing() + demo_chaining_operations() + demo_error_cases() + demo_multi_column_ordering() + + print("\n" + "=" * 70) + print("βœ… All demos completed!") + print("=" * 70) + print("\nKey Takeaways:") + print("1. Always set index explicitly for deterministic ordering") + print("2. Use .iloc for position-based access (with ordering)") + print("3. Use .loc for value-based filtering (on index column)") + print("4. Indexing works seamlessly with nested data extraction") + print("5. Chain operations: extract -> flatten -> index -> slice") + print("6. Multi-column ordering: like SQL ORDER BY col1 DESC, col2 ASC") + print("\nSee docs/indexing_guide.md for more details!") diff --git a/demos/demo_nested_handler_backend.py b/demos/demo_nested_handler_backend.py index 7c96f5f..a402156 100644 --- a/demos/demo_nested_handler_backend.py +++ b/demos/demo_nested_handler_backend.py @@ -9,7 +9,7 @@ - Automatic nested field extraction - Multi-table joins with clean syntax -APPROACH 2: prepare() + Direct Ibis (Power Users) +APPROACH 2: prepare() + Direct Ibis (Power Users) - Maximum flexibility for complex queries - WHERE, HAVING, window functions, CTEs - Full SQL power when you need it @@ -28,6 +28,7 @@ import sys from pathlib import Path + sys.path.insert(0, str(Path(__file__).parent.parent)) import ibis @@ -35,7 +36,7 @@ from leanframe.core.nested_handler import NestedHandler from demos.utils.create_nested_data import ( create_customers_for_join, - create_orders_for_join + create_orders_for_join, ) @@ -43,178 +44,183 @@ def main(): print("=" * 70) print("πŸš€ Comprehensive NestedHandler Demo with Backend Integration") print("=" * 70) - + # =================================================================== # STEP 1-2: Data Preparation - Create DataFrames and Push to DB # =================================================================== print("\n" + "=" * 70) print("STEP 1-2: Data Preparation") print("=" * 70) - + print("\nπŸ“Š Creating nested DataFrames...") customers_df = create_customers_for_join() orders_df = create_orders_for_join() - + print("βœ… Created two DataFrames:") print(" - Customers: email at profile.contact.email (nested 2 levels)") - print(" - Orders: email at shipping.recipient.email (nested 2 levels, different path!)") - + print( + " - Orders: email at shipping.recipient.email (nested 2 levels, different path!)" + ) + # Connect to local DuckDB print("\nπŸ”Œ Connecting to local DuckDB...") backend = ibis.duckdb.connect() session = leanframe.Session(backend=backend) - + # Push to database print("\nπŸ’Ύ Pushing tables to local database...") - + # Convert to pandas temporarily for database insertion customers_pandas = customers_df.to_pandas() orders_pandas = orders_df.to_pandas() - + # Register tables in DuckDB backend.create_table("customers", customers_pandas, overwrite=True) backend.create_table("orders", orders_pandas, overwrite=True) - + print("βœ… Tables created in database:") print(" - customers (4 records)") print(" - orders (5 records)") - + # =================================================================== # STEP 3: Create NestedHandler # =================================================================== print("\n" + "=" * 70) print("STEP 3: Create NestedHandler Orchestrator") print("=" * 70) - + nested = NestedHandler() print("βœ… Created NestedHandler instance") print(f" Current state: {nested}") - + # =================================================================== # STEP 4-5: Load from DB and Add to Handler # =================================================================== print("\n" + "=" * 70) print("STEP 4-5: Load Tables from Database β†’ DataFrameHandlers β†’ NestedHandler") print("=" * 70) - + print("\nπŸ“₯ Loading 'customers' table from database...") customers_table = backend.table("customers") customers_lf = session.DataFrame(customers_table.to_pandas()) - + # Add to NestedHandler with table qualifier for lineage nested.add( name="customers", df=customers_lf, - table_qualifier="local_duckdb.main.customers" # Full qualifier + table_qualifier="local_duckdb.main.customers", # Full qualifier ) - + print("\nπŸ“₯ Loading 'orders' table from database...") orders_table = backend.table("orders") orders_lf = session.DataFrame(orders_table.to_pandas()) - - nested.add( - name="orders", - df=orders_lf, - table_qualifier="local_duckdb.main.orders" - ) - + + nested.add(name="orders", df=orders_lf, table_qualifier="local_duckdb.main.orders") + print(f"\nβœ… NestedHandler now manages: {nested}") - + # =================================================================== # STEP 5A: APPROACH 1 - Convenience join() Method (Regular Users) # =================================================================== print("\n" + "=" * 70) print("STEP 5A: APPROACH 1 - Convenience join() Method") print("=" * 70) - + print("\n🎯 For Regular Users: Simple, intuitive API") print(" βœ… Automatic nested field extraction") print(" βœ… Clean multi-table join syntax") print(" βœ… No need to know about prepare() or Ibis") - + print("\nπŸ”— Joining customers and orders on email...") print(" - Customers: profile.contact.email (nested)") print(" - Orders: shipping.recipient.email (nested)") - + # Use convenience join() method - handles nested extraction automatically! # Note: You can use dot notation naturally - it gets converted to underscores internally joined_df = nested.join( tables={"c": "customers", "o": "orders"}, on=[("c", "profile.contact.email", "o", "shipping.recipient.email")], - how="inner" + how="inner", ) - + print("\nβœ… Join complete!") print(f" Result DataFrame has {len(joined_df.columns)} columns") - + # Add result to handler for tracking - nested.add("customer_orders_simple", joined_df, - table_qualifier="joined(local_duckdb.main.customersβ‹ˆlocal_duckdb.main.orders)") + nested.add( + "customer_orders_simple", + joined_df, + table_qualifier="joined(local_duckdb.main.customersβ‹ˆlocal_duckdb.main.orders)", + ) print(" Added to handler: 'customer_orders_simple'") print(f" NestedHandler now has: {len(nested)} DataFrames") - + # =================================================================== # STEP 5B: APPROACH 2 - prepare() + Ibis (Power Users) # =================================================================== print("\n" + "=" * 70) print("STEP 5B: APPROACH 2 - prepare() + Direct Ibis") print("=" * 70) - + print("\n⚑ For Power Users: Maximum flexibility") print(" βœ… Full control over SQL operations") print(" βœ… WHERE, HAVING, window functions") print(" βœ… Complex multi-table queries") - + print("\nπŸ”§ Step 1: Prepare DataFrames (extract nested fields)") customers_flat = nested.prepare("customers", verbose=False) orders_flat = nested.prepare("orders", verbose=False) print(f" Customers prepared: {len(customers_flat.columns)} columns") print(f" Orders prepared: {len(orders_flat.columns)} columns") - + print("\nπŸ”§ Step 2: Use direct Ibis operations") # Now we have full Ibis power! c_ibis = customers_flat._data o_ibis = orders_flat._data - + # Join on email joined_ibis = c_ibis.join( o_ibis, predicates=[c_ibis.profile_contact_email == o_ibis.shipping_recipient_email], - how="inner" + how="inner", ) - + # Can add WHERE clause (filter high-value orders) print(" Adding WHERE clause: amount > 100") # Note: Ibis operations - type checker may show warnings but code works filtered_ibis = joined_ibis.filter(joined_ibis.amount > 100) # type: ignore - + # Can add GROUP BY and aggregations print(" Adding GROUP BY: customer_id") grouped_ibis = filtered_ibis.group_by("customer_id").aggregate( total_amount=filtered_ibis.amount.sum(), # type: ignore - order_count=filtered_ibis.order_id.count() # type: ignore + order_count=filtered_ibis.order_id.count(), # type: ignore ) - + from leanframe.core.frame import DataFrame as LeanDF + power_user_result = LeanDF(grouped_ibis) - + print("\nβœ… Power user query complete!") print(f" Result has {len(power_user_result.columns)} columns") print(" With WHERE, GROUP BY, and aggregations") - + # Add to handler - nested.add("customer_summary", power_user_result, - table_qualifier="aggregated(customer_orders)") + nested.add( + "customer_summary", + power_user_result, + table_qualifier="aggregated(customer_orders)", + ) print(" Added to handler: 'customer_summary'") print(f" NestedHandler now has: {len(nested)} DataFrames") - + # =================================================================== # STEP 6: Store Results in Database # =================================================================== print("\n" + "=" * 70) print("STEP 6: Store Results Back to Database") print("=" * 70) - + # Store the simple join result print("\nπŸ’Ύ Writing convenience join result to database...") simple_result_handler = nested.get("customer_orders_simple") @@ -222,19 +228,23 @@ def main(): backend.create_table("customer_orders_simple", simple_pandas, overwrite=True) print("βœ… Created table: customer_orders_simple") print(f" Rows: {len(simple_pandas)}") - + # Store the power user result print("\nοΏ½ Writing power user summary to database...") summary_pandas = power_user_result.to_pandas() backend.create_table("customer_summary", summary_pandas, overwrite=True) print("βœ… Created table: customer_summary") print(f" Rows: {len(summary_pandas)}") - + # Show comparison print("\nπŸ“Š Results comparison:") - print(f" Simple join: {len(simple_pandas)} rows, {len(simple_pandas.columns)} columns") - print(f" Power user: {len(summary_pandas)} rows, {len(summary_pandas.columns)} columns (aggregated)") - + print( + f" Simple join: {len(simple_pandas)} rows, {len(simple_pandas.columns)} columns" + ) + print( + f" Power user: {len(summary_pandas)} rows, {len(summary_pandas.columns)} columns (aggregated)" + ) + # Show sample data from simple join print("\nπŸ“‹ Sample from simple join (first 2 records):") sample = simple_pandas.head(2) @@ -245,14 +255,14 @@ def main(): print(f" Email: {row.get('profile_contact_email', 'N/A')}") print(f" Order ID: {row.get('order_id', 'N/A')}") print(f" Order Amount: ${row.get('amount', 0):.2f}") - + # =================================================================== # STEP 7: Backend Reference Management - NEW Architecture! # =================================================================== print("\n" + "=" * 70) print("STEP 7: Backend Reference Management (Property-Based)") print("=" * 70) - + print("\nπŸ“š NEW Storage Architecture:") print(" Each DataFrameHandler now OWNS its backend reference!") print() @@ -265,10 +275,10 @@ def main(): print(" βœ… Can be None (in-memory) or backend identifier") print(" βœ… Independent backend status updates") print(" βœ… Clean separation of concerns") - + print("\nπŸ” Current backend status:") nested.show_backend_status() - + print("\nπŸ“‹ Detailed handler information:") for df_name in nested.list_dataframes(): handler = nested.get(df_name) @@ -279,50 +289,48 @@ def main(): if handler.has_backend_table(): print(f" Qualifier: {handler.table_qualifier}") print(f" Parsed: {backend_info}") - + # =================================================================== # STEP 8: Demonstrate Backend Reference Updates # =================================================================== print("\n" + "=" * 70) print("STEP 8: Backend Reference Updates (NEW Feature)") print("=" * 70) - + print("\nπŸ’‘ Demonstrating independent backend reference updates...") - + # Create an in-memory DataFrame print("\n1️⃣ Create in-memory DataFrame (no backend):") import pandas as pd - temp_df = session.DataFrame(pd.DataFrame({ - 'id': [1, 2, 3], - 'value': [10, 20, 30] - })) + + temp_df = session.DataFrame(pd.DataFrame({"id": [1, 2, 3], "value": [10, 20, 30]})) nested.add("temp_data", temp_df) # No table_qualifier - + temp_handler = nested.get("temp_data") print(f" Status: {temp_handler.has_backend_table()} (no backend table)") - + # Simulate saving to backend print("\n2️⃣ Save to backend database:") temp_pandas = temp_df.to_pandas() backend.create_table("temp_data", temp_pandas, overwrite=True) - + # Update the backend reference temp_handler.set_table_qualifier("local_duckdb.main.temp_data") print(f" Status: {temp_handler.has_backend_table()} (now has backend table!)") - + # Show updated status print("\n3️⃣ Show all backend status after update:") nested.show_backend_status() - + # Simulate dropping from backend print("\n4️⃣ Drop from backend (DataFrame still in memory):") backend.drop_table("temp_data") temp_handler.set_table_qualifier(None) # Clear backend reference print(f" Status: {temp_handler.has_backend_table()} (back to in-memory)") - + print("\n5️⃣ Final backend status:") nested.show_backend_status() - + print("\n✨ Benefits of NEW Architecture:") print(" βœ… Independent backend reference management") print(" βœ… DataFrames can update their own status") @@ -330,32 +338,40 @@ def main(): print(" βœ… Support for in-memory β†’ backend β†’ in-memory lifecycle") print(" βœ… Join provenance tracking (lineage from source tables)") print(" βœ… No tight coupling between orchestrator and backend state") - + # =================================================================== # Summary # =================================================================== print("\n" + "=" * 70) print("βœ… Demo Complete!") print("=" * 70) - + print("\nπŸ“Š Summary:") print(" - Created 2 nested DataFrames with email in different nested paths") print(" - Pushed to local DuckDB: customers, orders") print(" - Loaded from DB into NestedHandler (with table qualifiers)") - print(" - Joined on nested email: profile.contact.email β‹ˆ shipping.recipient.email") + print( + " - Joined on nested email: profile.contact.email β‹ˆ shipping.recipient.email" + ) print(" - Result auto-added to handler: 'customer_orders'") print(" - Saved joined result back to database") print(" - Demonstrated backend reference updates (save/drop lifecycle)") - + print("\n🎯 Key Architectural Insights:") - print(" 1. DataFrameHandler owns its backend reference (table_qualifier property)") + print( + " 1. DataFrameHandler owns its backend reference (table_qualifier property)" + ) print(" 2. NestedHandler orchestrates operations, handlers manage state") print(" 3. Backend references can be None (in-memory) or qualified names") - print(" 4. Handlers can independently update backend status via set_table_qualifier()") - print(" 5. Clean lifecycle: in-memory β†’ save (set qualifier) β†’ drop (clear qualifier)") + print( + " 4. Handlers can independently update backend status via set_table_qualifier()" + ) + print( + " 5. Clean lifecycle: in-memory β†’ save (set qualifier) β†’ drop (clear qualifier)" + ) print(" 6. Join results track lineage: joined(table1β‹ˆtable2)") print(" 7. Auto-add results enable fluent chaining of operations") - + # Cleanup backend.disconnect() print("\nπŸ”Œ Disconnected from database") diff --git a/demos/utils/create_data.py b/demos/utils/create_data.py index 16f778b..55d2546 100644 --- a/demos/utils/create_data.py +++ b/demos/utils/create_data.py @@ -66,4 +66,3 @@ def create_df_complex(session: leanframe.Session) -> DataFrame: session = leanframe.Session(backend=backend) df_simple = create_df_simple(session) df_complex = create_df_complex(session) - diff --git a/demos/utils/create_nested_data.py b/demos/utils/create_nested_data.py index b79a87a..6d48e0d 100644 --- a/demos/utils/create_nested_data.py +++ b/demos/utils/create_nested_data.py @@ -239,7 +239,7 @@ def create_deeply_nested_dataframe() -> DataFrame: def create_customers_for_join() -> DataFrame: """ Create customers DataFrame with nested email for join testing. - + Structure: customer_id, profile.contact.email (nested 2 levels) """ data = { @@ -247,38 +247,35 @@ def create_customers_for_join() -> DataFrame: "profile": [ { "name": "Alice Johnson", - "contact": {"email": "alice@example.com", "phone": "555-1001"} + "contact": {"email": "alice@example.com", "phone": "555-1001"}, }, { - "name": "Bob Smith", - "contact": {"email": "bob@example.com", "phone": "555-1002"} + "name": "Bob Smith", + "contact": {"email": "bob@example.com", "phone": "555-1002"}, }, { "name": "Charlie Brown", - "contact": {"email": "charlie@example.com", "phone": "555-1003"} + "contact": {"email": "charlie@example.com", "phone": "555-1003"}, }, { "name": "Diana Prince", - "contact": {"email": "diana@example.com", "phone": "555-1004"} - } - ] + "contact": {"email": "diana@example.com", "phone": "555-1004"}, + }, + ], } - - contact_schema = pa.struct([ - pa.field("email", pa.string()), - pa.field("phone", pa.string()) - ]) - - profile_schema = pa.struct([ - pa.field("name", pa.string()), - pa.field("contact", contact_schema) - ]) - - schema = pa.schema([ - pa.field("customer_id", pa.int64()), - pa.field("profile", profile_schema) - ]) - + + contact_schema = pa.struct( + [pa.field("email", pa.string()), pa.field("phone", pa.string())] + ) + + profile_schema = pa.struct( + [pa.field("name", pa.string()), pa.field("contact", contact_schema)] + ) + + schema = pa.schema( + [pa.field("customer_id", pa.int64()), pa.field("profile", profile_schema)] + ) + pa_table = pa.Table.from_pydict(data, schema=schema) return pyarrow_to_leanframe(pa_table) @@ -286,7 +283,7 @@ def create_customers_for_join() -> DataFrame: def create_orders_for_join() -> DataFrame: """ Create orders DataFrame with differently nested email for join testing. - + Structure: order_id, shipping.recipient.email (nested 2 levels, different path!) """ data = { @@ -294,44 +291,44 @@ def create_orders_for_join() -> DataFrame: "shipping": [ { "recipient": {"email": "alice@example.com", "name": "Alice J."}, - "address": "123 Main St" + "address": "123 Main St", }, { "recipient": {"email": "bob@example.com", "name": "Bob S."}, - "address": "456 Oak Ave" + "address": "456 Oak Ave", }, { "recipient": {"email": "alice@example.com", "name": "Alice J."}, - "address": "123 Main St" + "address": "123 Main St", }, { "recipient": {"email": "charlie@example.com", "name": "Charlie B."}, - "address": "789 Pine Rd" + "address": "789 Pine Rd", }, { "recipient": {"email": "bob@example.com", "name": "Bob S."}, - "address": "456 Oak Ave" - } + "address": "456 Oak Ave", + }, ], - "amount": [299.99, 150.00, 75.50, 420.00, 89.99] + "amount": [299.99, 150.00, 75.50, 420.00, 89.99], } - - recipient_schema = pa.struct([ - pa.field("email", pa.string()), - pa.field("name", pa.string()) - ]) - - shipping_schema = pa.struct([ - pa.field("recipient", recipient_schema), - pa.field("address", pa.string()) - ]) - - schema = pa.schema([ - pa.field("order_id", pa.int64()), - pa.field("shipping", shipping_schema), - pa.field("amount", pa.float64()) - ]) - + + recipient_schema = pa.struct( + [pa.field("email", pa.string()), pa.field("name", pa.string())] + ) + + shipping_schema = pa.struct( + [pa.field("recipient", recipient_schema), pa.field("address", pa.string())] + ) + + schema = pa.schema( + [ + pa.field("order_id", pa.int64()), + pa.field("shipping", shipping_schema), + pa.field("amount", pa.float64()), + ] + ) + pa_table = pa.Table.from_pydict(data, schema=schema) return pyarrow_to_leanframe(pa_table) diff --git a/docs/indexing_README.md b/docs/indexing_README.md new file mode 100644 index 0000000..0d501ee --- /dev/null +++ b/docs/indexing_README.md @@ -0,0 +1,258 @@ +# Indexing Feature - README + +## Quick Start + +```python +from leanframe.core.frame import DataFrame +import ibis + +# Create DataFrame +data = {'id': [1, 2, 3, 4, 5], 'value': [10, 20, 30, 40, 50]} +df = DataFrame(ibis.memtable(data)) + +# Set index (establishes ordering) +df = df.set_index('id', ascending=True) + +# Position-based indexing +first_row = df.iloc[0] # First row +first_three = df.iloc[0:3] # First 3 rows +from_third = df.iloc[3:] # All from 3rd onward + +# Label-based indexing +row_with_id_3 = df.loc[3] # Where id == 3 +range_2_to_4 = df.loc[2:4] # Where id BETWEEN 2 AND 4 +specific_ids = df.loc[[1, 3, 5]] # Where id IN (1, 3, 5) + +# Convenience methods +top_5 = df.head(5) # First 5 rows +bottom_5 = df.tail(5) # Last 5 rows +``` + +## Key Concepts + +### 1. Explicit Ordering Required + +Unlike pandas where row order is intrinsic, leanframe requires explicit ORDER BY specification: + +```python +# ❌ This fails - no ordering specified +df.iloc[0] # ValueError: Cannot use .iloc without an index + +# βœ… This works - explicit ordering +df.set_index('timestamp', ascending=False).iloc[0] # Newest record +``` + +**Why?** SQL databases (like BigQuery) don't have persistent row order. Making it explicit ensures deterministic, reproducible results. + +### 2. Index is a Specification, Not Data + +The index doesn't store values or create a new column. It's just metadata telling leanframe how to order rows: + +```python +df = df.set_index('customer_id') +# No data copied, no new column created +# Just: "when ordering is needed, use customer_id ASC" +``` + +### 3. Works Seamlessly with Nested Data + +Indexing integrates with leanframe's nested column handling: + +```python +from leanframe.core.frame import DataFrameHandler + +# DataFrame with nested columns +handler = DataFrameHandler(nested_df) + +# Extract nested fields +flat_df = handler.extract_nested_fields() + +# Index on extracted field +by_age = flat_df.set_index('person_age', ascending=False) +oldest_10 = by_age.head(10) +``` + +## Documentation + +- **[Quick Reference](indexing_quick_ref.md)** - API summary, common patterns +- **[Full Guide](indexing_guide.md)** - Comprehensive documentation +- **[Implementation](indexing_implementation.md)** - Design decisions, architecture + +## Examples + +See [`demos/demo_indexing_with_nested.py`](../demos/demo_indexing_with_nested.py) for 5 comprehensive examples: + +1. Basic indexing (`.iloc`, `.loc`, `.head()`, `.tail()`) +2. Nested data with indexing +3. Joins + indexing (via `NestedHandler`) +4. Chaining operations +5. Error handling + +Run it: +```bash +python demos/demo_indexing_with_nested.py +``` + +## Tests + +Run unit tests: +```bash +pytest tests/unit/test_indexing.py -v +``` + +Coverage includes: +- Index creation and properties +- `.iloc` position-based indexing +- `.loc` label-based indexing +- `.head()` and `.tail()` methods +- Integration with nested data +- Error conditions and edge cases + +## API Summary + +### Setting Index + +```python +df.set_index(column, ascending=True, name=None) β†’ DataFrame +``` + +### Position-Based (requires index) + +```python +df.iloc[0] # Single row +df.iloc[0:10] # Slice +df.iloc[10:] # Open-ended +``` + +### Label-Based (requires index) + +```python +df.loc[value] # Single value +df.loc[start:end] # Range (inclusive) +df.loc[[v1, v2, v3]] # Multiple values +``` + +### Convenience + +```python +df.head(n=5) # First n rows (with ordering) +df.tail(n=5) # Last n rows (requires index) +``` + +## Common Patterns + +### Time-Series: Most Recent + +```python +df.set_index('timestamp', ascending=False).head(100) +``` + +### Top N by Score + +```python +df.set_index('score', ascending=False).iloc[0:10] +``` + +### Filter by ID Range + +```python +df.set_index('customer_id').loc[10000:20000] +``` + +### Specific Records + +```python +df.set_index('order_id').loc[[5001, 5002, 5003]] +``` + +## Performance + +### Fast βœ… +- Setting index (metadata only) +- `.iloc` with positive indices β†’ `LIMIT/OFFSET` +- `.loc` with values β†’ `WHERE` clause +- `.head()` β†’ `LIMIT` + +### Slow ⚠️ +- `.iloc[-1]` (negative indexing requires full scan) +- Large offsets: `.iloc[1000000:]` + +### Not Supported ❌ +- Step slicing: `.iloc[::2]` +- List `.iloc`: `.iloc[[1, 3, 5]]` (coming soon) +- Multi-index (intentionally excluded) + +## Design Principles + +1. **SQL-First** - Every operation maps to SQL (LIMIT, OFFSET, WHERE, ORDER BY) +2. **Explicit** - No hidden assumptions about ordering +3. **Composable** - Works with all leanframe features +4. **Simple** - No multi-index complexity +5. **Familiar** - Pandas-like where possible + +## Comparison with Pandas + +| Feature | Pandas | Leanframe | +|---------|--------|-----------| +| Position access | `df.iloc[0]` | `df.set_index('col').iloc[0]` | +| Implicit order | βœ… Yes | ❌ No (explicit) | +| Multi-index | βœ… Yes | ❌ No | +| Negative indices | βœ… Fast | ⚠️ Slow | +| Label access | `df.loc[label]` | `df.set_index('col').loc[value]` | + +## Files + +``` +leanframe/ +β”œβ”€β”€ core/ +β”‚ β”œβ”€β”€ frame.py # Updated with indexing support +β”‚ └── indexing.py # New: Index, ILocIndexer, LocIndexer +β”œβ”€β”€ docs/ +β”‚ β”œβ”€β”€ indexing_guide.md # Full documentation +β”‚ β”œβ”€β”€ indexing_quick_ref.md # API reference +β”‚ β”œβ”€β”€ indexing_implementation.md # Design details +β”‚ └── indexing_README.md # This file +β”œβ”€β”€ demos/ +β”‚ └── demo_indexing_with_nested.py # Examples +└── tests/ + └── unit/ + └── test_indexing.py # Unit tests +``` + +## Future Enhancements + +### Planned +- [ ] List-based `.iloc`: `df.iloc[[1, 3, 5]]` +- [ ] Boolean indexing: `df.loc[df['amount'] > 100]` +- [ ] Reset index: `df.reset_index()` +- [ ] Index persistence across operations + +### Possible +- [ ] Composite ordering: `df.set_index(['col1', 'col2'])` +- [ ] Named indices with operations +- [ ] Index statistics and recommendations +- [ ] Smart caching for index metadata + +## Getting Help + +- **API Questions**: See [Quick Reference](indexing_quick_ref.md) +- **How-To**: See [Full Guide](indexing_guide.md) +- **Design Questions**: See [Implementation Docs](indexing_implementation.md) +- **Examples**: Run `demos/demo_indexing_with_nested.py` +- **Issues**: Check test cases in `tests/unit/test_indexing.py` + +## Contributing + +When adding indexing features: + +1. Keep SQL-first principle - operations should map to SQL +2. Maintain explicit ordering requirement +3. Add tests to `test_indexing.py` +4. Update documentation +5. Add examples to demo file +6. Consider BigQuery performance implications + +## License + +Copyright 2025 Google LLC, LeanFrame Authors +Licensed under Apache License 2.0 diff --git a/docs/indexing_guide.md b/docs/indexing_guide.md new file mode 100644 index 0000000..09eeae6 --- /dev/null +++ b/docs/indexing_guide.md @@ -0,0 +1,323 @@ +# Indexing in Leanframe + +This guide explains how indexing works in leanframe and how it differs from pandas. + +## Core Concept: Explicit Ordering + +Unlike pandas where row order is intrinsic, SQL databases (like BigQuery) have no persistent row ordering. Leanframe makes ordering **explicit** through the index specification. + +```python +# ❌ Pandas: assumes intrinsic row order +df.iloc[0:10] # First 10 rows (but which rows?) + +# βœ… Leanframe: explicit ordering +df.set_index('timestamp', ascending=False).iloc[0:10] # 10 newest records +``` + +## Setting an Index + +Use `.set_index()` to establish ordering: + +```python +# Single column - ascending order +df = df.set_index('timestamp') + +# Single column - descending order (newest first) +df = df.set_index('timestamp', ascending=False) + +# Multi-column ordering (SQL: ORDER BY col1, col2) +df = df.set_index(['priority', 'timestamp'], ascending=[False, True]) + +# Multi-column with same direction for all +df = df.set_index(['region', 'customer_id'], ascending=True) +``` + +**Important:** The index doesn't create a new column or modify data. It's a **specification** for how to order rows in subsequent operations. + +### Multi-Column Ordering + +When you specify multiple columns, leanframe creates a composite ordering like SQL's `ORDER BY` clause: + +```python +# Order by priority DESC, then timestamp ASC (for ties) +df = df.set_index(['priority', 'timestamp'], ascending=[False, True]) + +# Equivalent SQL: +# ORDER BY priority DESC, timestamp ASC +``` + +This is particularly useful for: +- **Breaking ties**: Primary sort by one column, secondary by another +- **Hierarchical data**: Sort by category, then subcategory, then item +- **Time series with groups**: Sort by group DESC, then timestamp ASC + +**Examples:** + +```python +# Customer data: group by region, then by signup date +df.set_index(['region', 'signup_date'], ascending=[True, False]) + +# Priority queue: highest priority first, earliest timestamp breaks ties +df.set_index(['priority', 'timestamp'], ascending=[False, True]) + +# Sales data: year DESC, quarter DESC, region ASC +df.set_index(['year', 'quarter', 'region'], ascending=[False, False, True]) +``` + +You can specify a single `ascending` value to apply to all columns: + +```python +# All ascending +df.set_index(['col1', 'col2', 'col3'], ascending=True) + +# All descending +df.set_index(['col1', 'col2', 'col3'], ascending=False) +``` + +## Position-Based Indexing (.iloc) + +Once an index is set, use `.iloc` for position-based access: + +```python +# Set ordering first +df = df.set_index('timestamp', ascending=False) + +# Single row +first = df.iloc[0] # Newest record + +# Slice +top_10 = df.iloc[0:10] # 10 newest records +next_10 = df.iloc[10:20] # Records 11-20 + +# Open-ended slices +from_10th = df.iloc[10:] # All records from 10th onward +``` + +**SQL Translation:** +```python +df.iloc[10:20] +# Translates to: +# SELECT * FROM table ORDER BY timestamp DESC LIMIT 10 OFFSET 10 +``` + +### Limitations + +- No step size: `df.iloc[::2]` not supported (SQL doesn't support stepping) +- Negative indices expensive: `df.iloc[-1]` requires full table scan +- List indexing not yet implemented: `df.iloc[[1, 3, 5]]` + +## Label-Based Indexing (.loc) + +Use `.loc` to filter by index column values: + +```python +# Set index on customer_id +df = df.set_index('customer_id') + +# Single value +customer = df.loc[12345] # WHERE customer_id = 12345 + +# Range +customers = df.loc[10000:20000] # WHERE customer_id BETWEEN 10000 AND 20000 + +# Multiple values +customers = df.loc[[12345, 67890, 11111]] # WHERE customer_id IN (...) +``` + +**SQL Translation:** +```python +df.loc[10000:20000] +# Translates to: +# SELECT * FROM table +# WHERE customer_id >= 10000 AND customer_id <= 20000 +# ORDER BY customer_id +``` + +## Convenience Methods + +### .head() and .tail() + +```python +# First 10 rows (requires index for deterministic results) +df.set_index('timestamp').head(10) + +# Last 10 rows (requires index) +df.set_index('timestamp').tail(10) + +# Default is 5 rows +df.head() +``` + +## Working with Nested Columns + +Indexing works seamlessly with nested column handling: + +```python +from leanframe.core.frame import DataFrameHandler + +# Create handler for nested DataFrame +handler = DataFrameHandler(nested_df) + +# Extract nested fields +flat_df = handler.extract_nested_fields() + +# Now apply indexing +flat_df = flat_df.set_index('person_age') +oldest_10 = flat_df.iloc[0:10] +``` + +**Example with joins:** + +```python +from leanframe.core.nested_handler import NestedHandler + +# Setup +handler = NestedHandler() +handler.add("customers", customers_df) +handler.add("orders", orders_df) + +# Prepare and join +joined = handler.join( + tables={"c": "customers", "o": "orders"}, + on=[("c", "customer_id", "o", "customer_id")] +) + +# Apply indexing to result +joined = joined.set_index('order_date', ascending=False) +recent_orders = joined.iloc[0:100] +``` + +## Best Practices + +### 1. Always Set Index for Position-Based Operations + +```python +# ❌ Bad: no explicit ordering +df.iloc[0:10] # Raises ValueError + +# βœ… Good: explicit ordering +df.set_index('id').iloc[0:10] +``` + +### 2. Choose Appropriate Index Columns + +Good index columns: +- βœ… Timestamps (for time-series data) +- βœ… Sequential IDs (for deterministic ordering) +- βœ… Monotonic values + +Avoid: +- ❌ Columns with many duplicates (unless that's intentional) +- ❌ Non-sortable types (complex nested structures) + +### 3. Use .loc for Value-Based Filtering + +```python +# ❌ Less efficient: filter then get first row +df.filter(df['customer_id'] == 12345).iloc[0] + +# βœ… More direct: use .loc +df.set_index('customer_id').loc[12345] +``` + +### 4. Consider BigQuery Performance + +```python +# Expensive: negative indexing requires full scan +df.iloc[-10:] # Counts all rows to get last 10 + +# Better: use descending index + positive slice +df.set_index('timestamp', ascending=False).iloc[0:10] +``` + +## Comparison with Pandas + +| Operation | Pandas | Leanframe | +|-----------|--------|-----------| +| Position access | `df.iloc[0]` | `df.set_index('col').iloc[0]` | +| Label access | `df.loc['label']` | `df.set_index('col').loc[value]` | +| Multi-index | Supported | **Not supported** | +| Negative indices | Fast | Slow (full scan) | +| Step slicing | `df.iloc[::2]` | **Not supported** | +| Index name | `df.index.name` | `df.index.name` | + +## Design Philosophy + +Leanframe's indexing design reflects SQL thinking: + +1. **No Hidden State:** Ordering is explicit, not implicit +2. **SQL-First:** Operations map cleanly to SQL (LIMIT, OFFSET, WHERE) +3. **Performance Aware:** Design discourages expensive operations +4. **Simplicity:** No multi-index complexity + +This makes leanframe code more **portable** to SQL and **predictable** in performance. + +## Migration from Pandas + +### Example: Time-Series Analysis + +**Pandas:** +```python +# Assumes data is already sorted +df.iloc[0:100] # First 100 rows +df.iloc[-100:] # Last 100 rows +``` + +**Leanframe:** +```python +# Make sorting explicit +df = df.set_index('timestamp', ascending=True) +df.iloc[0:100] # First 100 chronologically +df.set_index('timestamp', ascending=False).iloc[0:100] # Most recent 100 +``` + +### Example: Filtering by ID + +**Pandas:** +```python +df.set_index('customer_id') +customer = df.loc[12345] +``` + +**Leanframe:** +```python +# Same syntax! +df.set_index('customer_id') +customer = df.loc[12345] +``` + +## Advanced: Custom Ordering Logic + +For complex ordering (multiple columns, null handling), directly use Ibis: + +```python +# Complex ordering with Ibis +ordered = df._data.order_by([ + ibis.desc(df._data.priority), + df._data.timestamp +]) + +from leanframe.core.frame import DataFrame +df_ordered = DataFrame(ordered) + +# Now use indexing +df_ordered._index = Index('priority', ascending=False) +top_priority = df_ordered.iloc[0:10] +``` + +## Future Enhancements + +Planned features (not yet implemented): + +- [ ] List-based .iloc indexing: `df.iloc[[1, 3, 5]]` +- [ ] Boolean indexing: `df.loc[df['amount'] > 100]` +- [ ] Multi-column ordering (composite index) +- [ ] Index persistence across operations +- [ ] Reset index: `df.reset_index()` +- [ ] Set index from expression: `df.set_index(df['col1'] + df['col2'])` + +## See Also + +- [DataFrame Methods Spec](../specs/2025-10-27-dataframe-methods.md) +- [Nested Handler Vision](architecture/NESTED_HANDLER_VISION.md) +- [Ibis Documentation](https://ibis-project.org/) diff --git a/docs/indexing_implementation.md b/docs/indexing_implementation.md new file mode 100644 index 0000000..fd75cf3 --- /dev/null +++ b/docs/indexing_implementation.md @@ -0,0 +1,321 @@ +# Indexing Implementation Summary + +## What We Built + +A pandas-like indexing system for leanframe that's SQL-friendly and works seamlessly with BigQuery and nested data handling. + +## Files Created + +### 1. Core Implementation +- **`leanframe/core/indexing.py`** - Main indexing module + - `Index` class - Ordering specification (not data storage) + - `ILocIndexer` - Position-based indexing (`.iloc`) + - `LocIndexer` - Label-based indexing (`.loc`) + - `HeadTailMixin` - Convenience methods (`.head()`, `.tail()`) + +### 2. Documentation +- **`docs/indexing_guide.md`** - Comprehensive guide + - Core concepts and philosophy + - Detailed API documentation + - Working with nested data + - Best practices and patterns + - Migration guide from pandas + +- **`docs/indexing_quick_ref.md`** - Quick reference + - API summary + - Common patterns + - Error handling + - Performance tips + +### 3. Examples & Tests +- **`demos/demo_indexing_with_nested.py`** - Interactive demos + - 5 comprehensive examples + - Shows integration with nested data handling + - Error handling demonstrations + +- **`tests/unit/test_indexing.py`** - Unit tests + - 30+ test cases + - Coverage for all indexing features + - Edge cases and error conditions + - Integration with nested data + +### 4. Integration +- **Updated `leanframe/core/frame.py`** + - Added `Index`, `ILocIndexer`, `LocIndexer` imports + - Inherited `HeadTailMixin` in `DataFrame` class + - Added `.index`, `.iloc`, `.loc` properties + - Added `.set_index()` method + - Initialized indexing state in `__init__` + +## Key Design Decisions + +### 1. Explicit Ordering (Not Implicit) + +**Problem:** SQL databases don't have intrinsic row order like pandas. + +**Solution:** Require explicit ORDER BY specification via `.set_index()`. + +```python +# ❌ Pandas: assumes intrinsic order +df.iloc[0:10] + +# βœ… Leanframe: explicit order +df.set_index('timestamp', ascending=False).iloc[0:10] +``` + +**Why:** This forces users to think about ordering, preventing non-deterministic results. + +### 2. Index as Specification (Not Data) + +**Problem:** Pandas Index stores actual values; impractical for large SQL tables. + +**Solution:** Index is a lightweight specification for ORDER BY clauses. + +```python +class Index: + def __init__(self, column: str, ascending: bool = True, name: str | None = None): + self.column = column # Which column to order by + self.ascending = ascending # ASC or DESC + self.name = name # Optional name +``` + +**Why:** No data materialization; just metadata for SQL generation. + +### 3. Single Index Only (No Multi-Index) + +**Problem:** Multi-index adds complexity and doesn't map cleanly to SQL. + +**Solution:** Support only single-column ordering. + +**Future:** Could extend to composite ordering via: +```python +df.set_index(['priority', 'timestamp'], ascending=[False, True]) +``` + +### 4. SQL-First Operation Mapping + +Every indexing operation maps directly to SQL: + +| Operation | SQL Translation | +|-----------|-----------------| +| `df.iloc[0:10]` | `LIMIT 10 OFFSET 0` | +| `df.iloc[10:20]` | `LIMIT 10 OFFSET 10` | +| `df.loc[123]` | `WHERE col = 123` | +| `df.loc[100:200]` | `WHERE col BETWEEN 100 AND 200` | +| `df.head(10)` | `LIMIT 10` | +| `df.tail(10)` | `LIMIT 10 (reversed order)` | + +**Why:** Predictable performance; users understand what's happening. + +### 5. Integration with Nested Data + +Indexing works seamlessly with existing nested data handling: + +```python +# Extract nested fields +handler = DataFrameHandler(nested_df) +flat_df = handler.extract_nested_fields() + +# Apply indexing +by_age = flat_df.set_index('person_age', ascending=False) +oldest = by_age.head(10) +``` + +**Why:** Composability - each feature works independently and together. + +## Architecture + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ DataFrame β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Properties: β”‚ β”‚ +β”‚ β”‚ - .index β†’ Index (ordering spec) β”‚ β”‚ +β”‚ β”‚ - .iloc β†’ ILocIndexer (position-based) β”‚ β”‚ +β”‚ β”‚ - .loc β†’ LocIndexer (label-based) β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ Methods: β”‚ β”‚ +β”‚ β”‚ - .set_index(col, asc) β†’ DataFrame β”‚ β”‚ +β”‚ β”‚ - .head(n) β†’ DataFrame β”‚ β”‚ +β”‚ β”‚ - .tail(n) β†’ DataFrame β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ +β”‚ β”‚ uses β”‚ +β”‚ β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Index β”‚ β”‚ +β”‚ β”‚ - column: str β”‚ β”‚ +β”‚ β”‚ - ascending: bool β”‚ β”‚ +β”‚ β”‚ - name: str β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ +β”‚ β”‚ used by β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β–Ό β–Ό β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ ILocIndexer β”‚ β”‚ LocIndexer β”‚ β”‚ +β”‚ β”‚ - [int] β”‚ β”‚ - [value] β”‚ β”‚ +β”‚ β”‚ - [slice] β”‚ β”‚ - [slice] β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ - [list] β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β”‚ generates + β–Ό + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ Ibis SQL β”‚ + β”‚ - ORDER BY β”‚ + β”‚ - LIMIT/OFFSET β”‚ + β”‚ - WHERE β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +## Usage Examples + +### Basic Indexing + +```python +# Create DataFrame +df = DataFrame(ibis.memtable({'id': [1, 2, 3], 'value': [10, 20, 30]})) + +# Set index +df = df.set_index('id') + +# Position-based +first = df.iloc[0] # First row +top_2 = df.iloc[0:2] # First 2 rows + +# Label-based +row = df.loc[2] # Where id=2 +range_rows = df.loc[1:3] # Where id BETWEEN 1 AND 3 + +# Convenience +first_5 = df.head(5) +last_5 = df.tail(5) +``` + +### With Nested Data + +```python +from leanframe.core.frame import DataFrameHandler + +# Nested DataFrame +nested_df = DataFrame(ibis.memtable({ + 'id': [1, 2, 3], + 'person': [ + {'name': 'Alice', 'age': 30}, + {'name': 'Bob', 'age': 25}, + {'name': 'Carol', 'age': 35} + ] +})) + +# Extract nested fields +handler = DataFrameHandler(nested_df) +flat_df = handler.extract_nested_fields() + +# Index on extracted field +by_age = flat_df.set_index('person_age', ascending=False) +oldest = by_age.iloc[0] # Carol +``` + +### With Joins + +```python +from leanframe.core.nested_handler import NestedHandler + +# Setup +handler = NestedHandler() +handler.add("customers", customers_df) +handler.add("orders", orders_df) + +# Join +joined = handler.join( + tables={"c": "customers", "o": "orders"}, + on=[("c", "customer_id", "o", "customer_id")] +) + +# Index and slice +by_date = joined.set_index('order_date', ascending=False) +recent_100 = by_date.iloc[0:100] +``` + +## Performance Characteristics + +### Fast Operations βœ… +- Setting index (metadata only) +- `.iloc` with positive indices +- `.loc` with single value or range +- `.head()` with explicit index + +### Slow Operations ⚠️ +- `.iloc` with negative indices (requires full scan) +- `.tail()` without index (error) +- Large offset in `.iloc[1000000:]` + +### Not Supported ❌ +- Step slicing: `df.iloc[::2]` +- List-based `.iloc`: `df.iloc[[1, 3, 5]]` +- Multi-index + +## Testing + +Run the tests: + +```bash +# All indexing tests +pytest tests/unit/test_indexing.py -v + +# Specific test class +pytest tests/unit/test_indexing.py::TestILocIndexing -v + +# Run demos +python demos/demo_indexing_with_nested.py +``` + +## Next Steps + +### Immediate Enhancements +1. **List-based .iloc**: `df.iloc[[1, 3, 5]]` using row_number() window function +2. **Boolean indexing**: `df.loc[df['amount'] > 100]` +3. **Index persistence**: Keep index across operations like `.assign()` +4. **Reset index**: `df.reset_index()` method + +### Future Features +1. **Composite ordering**: Multi-column index via tuple +2. **Named indices**: Better support for index names +3. **Index operations**: `.reindex()`, `.sort_index()` +4. **Integration with groupby**: Use index in aggregations + +### Advanced Features +1. **Optimized negative indexing**: Use window functions instead of reversing +2. **Index caching**: Cache index metadata across sessions +3. **Smart index selection**: Auto-suggest best index column +4. **Index statistics**: Show coverage, cardinality, etc. + +## Design Philosophy + +Leanframe's indexing embodies **SQL-first thinking**: + +1. **Explicit over Implicit** - No hidden assumptions +2. **Predictable Performance** - Operations map to known SQL patterns +3. **Composability** - Works with all existing features +4. **Simplicity** - No multi-index complexity +5. **Familiarity** - Pandas-like API where possible + +This makes code: +- **Portable** - Easy to translate to pure SQL +- **Debuggable** - Clear what's happening under the hood +- **Scalable** - No performance surprises +- **Maintainable** - Simple mental model + +## Conclusion + +We've built a complete indexing system that: +- βœ… Provides pandas-like API (`.iloc`, `.loc`, `.head()`, `.tail()`) +- βœ… Works with SQL databases (BigQuery, etc.) +- βœ… Integrates with nested data handling +- βœ… Has clear performance characteristics +- βœ… Is well-documented and tested +- βœ… Follows SQL-first principles + +The system is **production-ready** for basic use cases and has a clear path for future enhancements. diff --git a/docs/indexing_quick_ref.md b/docs/indexing_quick_ref.md new file mode 100644 index 0000000..864b053 --- /dev/null +++ b/docs/indexing_quick_ref.md @@ -0,0 +1,333 @@ +# Indexing Quick Reference + +## Overview + +Leanframe provides pandas-like indexing with SQL semantics. Since BigQuery and other SQL databases don't have persistent row ordering, indexing requires **explicit ORDER BY specification**. + +## Key Concept + +```python +# ❌ Pandas: implicit row order +df.iloc[0:10] + +# βœ… Leanframe: explicit order specification +df.set_index('timestamp', ascending=False).iloc[0:10] +``` + +## API Reference + +### Setting an Index + +#### `DataFrame.set_index(columns, ascending=True, name=None)` + +Establishes deterministic row ordering for position-based operations. + +**Parameters:** +- `columns` (str or list[str]): Column name(s) to order by +- `ascending` (bool or list[bool]): Sort direction(s) (True=ASC, False=DESC) +- `name` (str, optional): Name for the index (defaults to first column name) + +**Returns:** New DataFrame with index set + +**Example:** +```python +# Single column - ascending order +df = df.set_index('customer_id') + +# Single column - descending order +df = df.set_index('timestamp', ascending=False) + +# Multi-column ordering (like SQL ORDER BY col1 DESC, col2 ASC) +df = df.set_index(['priority', 'timestamp'], ascending=[False, True]) + +# Multi-column with single ascending for all +df = df.set_index(['region', 'customer_id'], ascending=True) + +# With custom name +df = df.set_index('score', ascending=False, name='rank') +``` + +**Multi-Column Ordering:** +When multiple columns are specified, they define a composite ordering just like SQL's `ORDER BY col1, col2, col3`. The first column is the primary sort, with subsequent columns breaking ties: + +```python +# SQL equivalent: ORDER BY priority DESC, timestamp ASC, id ASC +df.set_index(['priority', 'timestamp', 'id'], ascending=[False, True, True]) +``` + +--- + +### Position-Based Indexing (.iloc) + +#### `DataFrame.iloc[key]` + +Access rows by position using explicit ordering. + +**Requires:** Index must be set first + +**Supported keys:** +- `int`: Single row by position +- `slice`: Range of rows (e.g., `0:10`, `5:`, `:20`) + +**Returns:** DataFrame (or Series for single row) + +**Example:** +```python +df = df.set_index('timestamp', ascending=False) + +# Single row +first = df.iloc[0] + +# Slice +top_10 = df.iloc[0:10] +next_10 = df.iloc[10:20] +from_50 = df.iloc[50:] +``` + +**SQL Translation:** +```python +df.iloc[10:20] +# β†’ SELECT * FROM table ORDER BY timestamp DESC LIMIT 10 OFFSET 10 +``` + +**Limitations:** +- ❌ Step size not supported: `df.iloc[::2]` +- ⚠️ Negative indices expensive: `df.iloc[-1]` (requires full scan) +- ❌ List indexing not implemented: `df.iloc[[1, 3, 5]]` + +--- + +### Label-Based Indexing (.loc) + +#### `DataFrame.loc[key]` + +Filter rows by index column values. + +**Requires:** Index must be set first + +**Supported keys:** +- Single value: `df.loc[12345]` +- Slice: `df.loc[1000:2000]` (inclusive range) +- List: `df.loc[[val1, val2, val3]]` + +**Returns:** DataFrame + +**Example:** +```python +df = df.set_index('customer_id') + +# Single value +customer = df.loc[12345] + +# Range +customers = df.loc[10000:20000] + +# Multiple values +customers = df.loc[[12345, 67890, 11111]] +``` + +**SQL Translation:** +```python +df.loc[10000:20000] +# β†’ SELECT * FROM table +# WHERE customer_id >= 10000 AND customer_id <= 20000 +# ORDER BY customer_id +``` + +--- + +### Convenience Methods + +#### `DataFrame.head(n=5)` + +Return first n rows based on index ordering. + +**Parameters:** +- `n` (int): Number of rows (default: 5) + +**Returns:** DataFrame + +**Example:** +```python +df.set_index('timestamp').head(10) + +# Works without index (arbitrary order) +df.head() +``` + +--- + +#### `DataFrame.tail(n=5)` + +Return last n rows based on index ordering. + +**Requires:** Index must be set + +**Parameters:** +- `n` (int): Number of rows (default: 5) + +**Returns:** DataFrame + +**Example:** +```python +df.set_index('timestamp').tail(10) +``` + +**Note:** Requires index for deterministic results. + +--- + +### Index Object + +#### `DataFrame.index` + +Access the index (ordering specification) for the DataFrame. + +**Returns:** `Index` object or `None` + +**Example:** +```python +df = df.set_index('timestamp', ascending=False) +print(df.index) # Index('timestamp', descending) +print(df.index.column) # 'timestamp' +print(df.index.ascending) # False +``` + +--- + +## Working with Nested Data + +### Extract then Index + +```python +from leanframe.core.frame import DataFrameHandler + +# 1. Create handler +handler = DataFrameHandler(nested_df) + +# 2. Extract nested fields +flat_df = handler.extract_nested_fields() + +# 3. Apply indexing +by_age = flat_df.set_index('person_age', ascending=False) +oldest = by_age.head(10) +``` + +### Join then Index + +```python +from leanframe.core.nested_handler import NestedHandler + +# 1. Setup handler +handler = NestedHandler() +handler.add("customers", customers_df) +handler.add("orders", orders_df) + +# 2. Join +joined = handler.join( + tables={"c": "customers", "o": "orders"}, + on=[("c", "customer_id", "o", "customer_id")] +) + +# 3. Index and slice +by_date = joined.set_index('order_date', ascending=False) +recent = by_date.iloc[0:100] +``` + +--- + +## Common Patterns + +### Time-Series: Most Recent Records + +```python +df.set_index('timestamp', ascending=False).head(100) +``` + +### Time-Series: Oldest Records + +```python +df.set_index('timestamp', ascending=True).head(100) +``` + +### Top N by Score + +```python +df.set_index('score', ascending=False).iloc[0:10] +``` + +### Filter by ID Range + +```python +df.set_index('customer_id').loc[10000:20000] +``` + +### Get Specific Records + +```python +df.set_index('order_id').loc[[5001, 5002, 5003]] +``` + +--- + +## Error Handling + +### Using .iloc without index + +```python +df.iloc[0] # ❌ ValueError: Cannot use .iloc without an index +df.set_index('id').iloc[0] # βœ… OK +``` + +### Using .loc without index + +```python +df.loc[123] # ❌ ValueError: Cannot use .loc without an index +df.set_index('id').loc[123] # βœ… OK +``` + +### Invalid column name + +```python +df.set_index('nonexistent') # ❌ KeyError: Column 'nonexistent' not found +``` + +--- + +## Performance Tips + +### βœ… DO + +- Set index on columns with good selectivity +- Use positive indices: `df.iloc[0:100]` +- Use `.loc` for value filtering: `df.loc[10000:20000]` +- Order by indexed/primary key columns when possible + +### ❌ AVOID + +- Negative indices: `df.iloc[-1]` (requires full scan) +- Step slicing: `df.iloc[::2]` (not supported) +- Indexing on columns with poor selectivity +- Repeated materialization in loops + +--- + +## Comparison with Pandas + +| Feature | Pandas | Leanframe | +|---------|--------|-----------| +| Position access | `df.iloc[0]` | `df.set_index('col').iloc[0]` | +| Label access | `df.loc['label']` | `df.set_index('col').loc[value]` | +| Multi-index | βœ… Supported | ❌ Not supported | +| Implicit order | βœ… Yes | ❌ No (explicit required) | +| Negative indices | βœ… Fast | ⚠️ Slow (full scan) | +| Step slicing | βœ… `df.iloc[::2]` | ❌ Not supported | + +--- + +## See Also + +- [Full Indexing Guide](../docs/indexing_guide.md) - Detailed documentation +- [Demo with Nested Data](../demos/demo_indexing_with_nested.py) - Examples +- [Unit Tests](../tests/unit/test_indexing.py) - Test coverage +- [DataFrame Methods Spec](../specs/2025-10-27-dataframe-methods.md) diff --git a/leanframe/core/frame.py b/leanframe/core/frame.py index ae2e2ad..d59efe4 100644 --- a/leanframe/core/frame.py +++ b/leanframe/core/frame.py @@ -19,11 +19,19 @@ import ibis import ibis.expr.types as ibis_types import pandas as pd +from functools import reduce +import operator from leanframe.core.dtypes import convert_ibis_to_pandas +from leanframe.core.indexing import ( + Index, + ILocIndexer, + LocIndexer, + HeadTailMixin, +) -class DataFrame: +class DataFrame(HeadTailMixin): """A 2D data structure, representing data and deferred computation. WARNING: Do not call this constructor directly. Use the factory methods on @@ -32,12 +40,73 @@ class DataFrame: def __init__(self, data: ibis_types.Table): self._data = data + self._index: Index | None = None # Explicit ordering specification + + # Create indexers (lazy - only instantiated when accessed) + self._iloc: ILocIndexer | None = None + self._loc: LocIndexer | None = None @property def columns(self) -> pd.Index: """The column labels of the DataFrame.""" return pd.Index(self._data.columns, dtype="object") + @property + def index(self) -> Index | None: + """ + The index (ordering specification) for this DataFrame. + + Returns None if no index is set. Use .set_index() to establish + deterministic ordering for position-based operations. + + Example: + df = df.set_index('timestamp', ascending=False) + print(df.index) # Index('timestamp', descending) + """ + return self._index + + @property + def iloc(self) -> ILocIndexer: + """ + Position-based indexing with explicit ordering. + + Requires an index to be set via .set_index() for deterministic results. + + Returns: + ILocIndexer for position-based access + + Raises: + ValueError: If accessed without setting an index first + + Example: + df = df.set_index('timestamp', ascending=False) + newest_10 = df.iloc[0:10] + """ + if self._iloc is None: + self._iloc = ILocIndexer(self) + return self._iloc + + @property + def loc(self) -> LocIndexer: + """ + Label-based indexing on index column values. + + Requires an index to be set via .set_index(). + + Returns: + LocIndexer for label-based access + + Raises: + ValueError: If accessed without setting an index first + + Example: + df = df.set_index('customer_id') + customer = df.loc[12345] + """ + if self._loc is None: + self._loc = LocIndexer(self) + return self._loc + @property def dtypes(self) -> pd.Series: """Return the dtypes in the DataFrame.""" @@ -94,6 +163,79 @@ def to_ibis(self) -> ibis_types.Table: """Return the underlying Ibis expression.""" return self._data + def set_index( + self, + columns: str | list[str], + ascending: bool | list[bool] = True, + name: str | None = None, + ) -> DataFrame: + """ + Set the index (ordering specification) for this DataFrame. + + This establishes deterministic row ordering for position-based + operations like .iloc, .head(), and .tail(). + + Unlike pandas, this does NOT modify the DataFrame structure or + create a new column. It only specifies how rows should be ordered + for subsequent operations. + + Supports both single-column and multi-column ordering (like SQL's + ORDER BY col1, col2, col3). For multi-column ordering, the order + of columns determines their priority. + + Args: + columns: Column name(s) to order by. Can be: + - Single string: 'timestamp' + - List of strings: ['priority', 'timestamp'] + Works with nested columns after extraction: + - ['person_age', 'person_name'] + ascending: Sort direction(s). Can be: + - Single bool: True (applies to all columns) + - List of bools: [False, True] (one per column) + name: Optional name for the index (defaults to column name) + + Returns: + New DataFrame with index set (original DataFrame unchanged) + + Raises: + KeyError: If any column doesn't exist + ValueError: If ascending list length doesn't match columns list length + + Example: + # Single column ordering + df = df.set_index('timestamp', ascending=False) + newest = df.iloc[0] + + # Multi-column ordering (ORDER BY priority DESC, timestamp DESC) + df = df.set_index(['priority', 'timestamp'], ascending=[False, False]) + top_urgent = df.iloc[0:10] + + # Works with nested columns after extraction + handler = DataFrameHandler(nested_df) + flat = handler.extract_nested_fields() + by_age_name = flat.set_index(['person_age', 'person_name'], + ascending=[False, True]) + + # Chain with other operations + top_10 = df.set_index('score', ascending=False).head(10) + """ + # Normalize columns to list + col_list = [columns] if isinstance(columns, str) else list(columns) + + # Validate all columns exist + for col in col_list: + if col not in self._data.columns: + available = ", ".join(self._data.columns) + raise KeyError( + f"Column '{col}' not found. Available columns: {available}" + ) + + # Create new DataFrame with index set + new_df = DataFrame(self._data) + new_df._index = Index(columns, ascending=ascending, name=name) + + return new_df + """ Dynamic Nested Data Handler for leanframe @@ -102,78 +244,76 @@ def to_ibis(self) -> ibis_types.Table: and automatically handle nested columns of any depth and structure. """ + # TODO: replace prints by logging, ask TIM about logger usage class DataFrameHandler: """ Wrapper for a single leanframe DataFrame that handles nested column introspection. - + This class wraps ONE DataFrame and provides metadata about its nested structure along with functional operations for extracting nested fields. - + Design Philosophy - Stateless Data, Cached Metadata: - Wraps a SINGLE leanframe DataFrame (not multiple DataFrames) - Caches IMMUTABLE schema metadata (nested field mappings, column types) - Does NOT cache data or extraction results - All data operations return NEW DataFrame objects (functional style) - Thread-safe for concurrent operations - + Features: - Automatic nested structure detection via schema introspection - Multi-level nesting support (configurable depth) - Dynamic field extraction (computed on-demand) - Preserves non-nested columns - Efficient columnar operations - + Usage Pattern: # Create handler for a single DataFrame (introspects schema once) handler = DataFrameHandler(customers_df) - + # Access cached schema metadata (fast, immutable) print(handler.extracted_fields) # {'person.name': 'person_name', ...} handler.show_structure() - + # Compute data operations (functional, returns new objects) extracted_df = handler.extract_nested_fields() # No caching! another_df = handler.extract_nested_fields() # Computes again - + # For multi-DataFrame operations, use NestedHandler orchestrator # (See NestedHandler class for joins and other cross-DataFrame operations) """ def __init__( - self, - lf_df: DataFrame, - max_depth: int = 10, - table_qualifier: str | None = None + self, lf_df: DataFrame, max_depth: int = 10, table_qualifier: str | None = None ): """ Initialize handler by introspecting DataFrame schema. - + Args: lf_df: leanframe DataFrame to analyze max_depth: Maximum nesting depth to analyze (prevents infinite recursion) table_qualifier: Optional backend table identifier (e.g., "project.dataset.table") Can be None for in-memory DataFrames. Can be updated later via set_table_qualifier() method. - + Note: Constructor only analyzes SCHEMA (metadata), does not extract data. This makes handler creation fast and allows metadata reuse. """ # Store reference to original DataFrame (not a copy!) self.original_df = lf_df - + # Configuration self.max_depth = max_depth - + # Backend reference - can be None for in-memory DataFrames # This is mutable and can be updated when DataFrame is saved to backend self._table_qualifier: str | None = table_qualifier - + # Cached schema metadata (immutable after introspection) self.nested_fields: dict[str, dict] = {} # Maps path -> field metadata self.struct_columns: set[str] = set() # Tracks struct columns - + # Perform schema introspection (builds metadata cache) self._introspect_structure() @@ -288,25 +428,25 @@ def _extract_nested_fields_silent(self) -> DataFrame: def extract_nested_fields(self, verbose: bool = True) -> DataFrame: """ Extract all discovered nested fields into a flat DataFrame. - + IMPORTANT: This is a FUNCTIONAL operation that returns a NEW DataFrame. Results are NOT cached - each call computes fresh extraction. This ensures thread-safety and prevents stale data issues. - + Args: verbose: If True, prints extraction progress. Set False for silent operation. - + Returns: NEW DataFrame with flattened structure (does not modify original) - + Example: - handler = DynamicNestedHandler(nested_df) + handler = DataFrameHandler(nested_df) flat1 = handler.extract_nested_fields() # Computes extraction flat2 = handler.extract_nested_fields() # Computes again (no cache!) """ if not verbose: return self._extract_nested_fields_silent() - + print("\nπŸš€ Extracting all nested fields...") ibis_table = self.original_df._data @@ -354,7 +494,7 @@ def extract_nested_fields(self, verbose: bool = True) -> DataFrame: def original_columns(self) -> list[str]: """ Get original DataFrame column names. - + Returns: List of column names from the original DataFrame """ @@ -364,24 +504,48 @@ def original_columns(self) -> list[str]: def extracted_fields(self) -> dict[str, str]: """ Get mapping of nested paths to extracted column names. - + This returns CACHED SCHEMA METADATA, not data. Example: {'person.name': 'person_name', 'person.age': 'person_age'} - + Returns: Dictionary mapping nested paths to flattened column names """ return { path: info["extracted_name"] for path, info in self.nested_fields.items() } - + + def filter_by(self, **kwargs) -> "DataFrameHandler": + """ + Return a new DataFrameHandler filtered by one or more column==value pairs. + Args: + kwargs: column=value pairs to filter on (must be flattened/extracted columns). + Returns: + New DataFrameHandler with filtered records. + Example: + handler.filter_by(person_age=30, person_city="Berlin") + """ + flat_df = self.extract_nested_fields(verbose=False) + ibis_table = flat_df._data + conditions = [] + for column, value in kwargs.items(): + if column not in ibis_table.columns: + raise KeyError(f"Column '{column}' not found in DataFrame.") + conditions.append(ibis_table[column] == value) + if not conditions: + raise ValueError("At least one column=value filter must be provided.") + combined = reduce(operator.and_, conditions) + filtered_table = ibis_table.filter(combined) + filtered_lf_df = DataFrame(filtered_table) + return DataFrameHandler(filtered_lf_df) + def get_extracted_column_name(self, nested_path: str) -> str | None: """ Look up the extracted column name for a nested path. - + Args: nested_path: Nested path like 'person.address.city' - + Returns: Extracted column name like 'person_address_city', or None if not found """ @@ -392,17 +556,17 @@ def get_extracted_column_name(self, nested_path: str) -> str | None: def columns(self) -> list[str]: """ Get column names from extracted DataFrame (computed on-demand). - + WARNING: This performs extraction on EVERY call - use sparingly. For efficiency, call extract_nested_fields() once and reuse the result. """ extracted = self._extract_nested_fields_silent() return extracted.columns.tolist() - + def get_column(self, column_name: str) -> list: """ Get entire column data (computed on-demand, not cached). - + WARNING: This extracts and materializes data on EVERY call. For efficiency, call extract_nested_fields() once and work with that. """ @@ -412,11 +576,11 @@ def get_column(self, column_name: str) -> list: available = ", ".join(pandas_df.columns) raise KeyError(f"Column '{column_name}' not found. Available: {available}") return pandas_df[column_name].tolist() - + def get_record(self, index: int) -> dict: """ Get single record as dictionary (computed on-demand). - + WARNING: Performs extraction and materialization on EVERY call. Not efficient for iterating over records - use extract_nested_fields() instead. """ @@ -425,44 +589,44 @@ def get_record(self, index: int) -> dict: if index >= len(pandas_df): raise IndexError(f"Index {index} out of range (0-{len(pandas_df) - 1})") return pandas_df.iloc[index].to_dict() - + def __len__(self) -> int: """ Get number of records from original DataFrame. - + Note: Uses original DataFrame to avoid extraction overhead. """ # Use original DataFrame to avoid extraction overhead pandas_df = self.original_df.to_pandas() return len(pandas_df) - + def __getitem__(self, index: int) -> dict: """Get record by index.""" return self.get_record(index) - + def __iter__(self): """Iterate over records.""" for i in range(len(self)): yield self.get_record(i) - + def __contains__(self, key: str) -> bool: """Check if column exists in extracted fields.""" return key in self.columns - + def keys(self): """Get all column names.""" return self.columns - + def items(self): """Iterate over (column_name, column_data) pairs.""" for key in self.keys(): yield key, self.get_column(key) - + def values(self): """Iterate over column data.""" for key in self.keys(): yield self.get_column(key) - + def get(self, key: str, default=None): """Dictionary-like get with default value.""" try: @@ -473,13 +637,13 @@ def get(self, key: str, default=None): def show_structure(self): """ Display the complete DataFrame structure analysis. - + Shows CACHED SCHEMA METADATA: - Original columns and their types - Discovered nested fields and their paths - Expected flattened column structure - Record count from original DataFrame - + This is a read-only view of cached metadata, not data extraction. """ print("\nπŸ“‹ DYNAMIC STRUCTURE ANALYSIS") @@ -497,7 +661,9 @@ def show_structure(self): print(f" πŸ”— {original_path} β†’ {extracted_name}") # Show what the flattened structure would look like - expected_columns = [col for col in self.original_columns if col not in self.struct_columns] + expected_columns = [ + col for col in self.original_columns if col not in self.struct_columns + ] expected_columns.extend(self.extracted_fields.values()) print(f"\nFlattened columns ({len(expected_columns)} total):") for col in expected_columns: @@ -508,53 +674,53 @@ def show_structure(self): print(f"\nRecords: {len(pandas_preview)}") # Backend reference management - + @property def table_qualifier(self) -> str | None: """ Get the backend table qualifier for this DataFrame. - + Returns: Backend table identifier (e.g., "project.dataset.table") or None if this is an in-memory DataFrame not yet persisted to backend. - + Example: handler = DataFrameHandler(df) print(handler.table_qualifier) # None (in-memory) - + # After saving to backend handler.set_table_qualifier("mydb.sales.customers") print(handler.table_qualifier) # "mydb.sales.customers" """ return self._table_qualifier - + def set_table_qualifier(self, qualifier: str | None): """ Update the backend table qualifier for this DataFrame. - + This should be called when: - DataFrame is saved to a backend table - DataFrame is loaded from a backend table - Backend table is dropped (set to None) - DataFrame is renamed in the backend - + Args: qualifier: New backend table identifier or None to clear - + Example: handler = DataFrameHandler(in_memory_df) - + # Save to backend backend.create_table("customers", df.to_pandas()) handler.set_table_qualifier("mydb.main.customers") - + # Later, if table is dropped backend.drop_table("customers") handler.set_table_qualifier(None) # DataFrame still in memory """ old_qualifier = self._table_qualifier self._table_qualifier = qualifier - + if old_qualifier != qualifier: if qualifier is None: print(f"πŸ”— Cleared backend reference (was: {old_qualifier})") @@ -562,20 +728,20 @@ def set_table_qualifier(self, qualifier: str | None): print(f"πŸ”— Set backend reference: {qualifier}") else: print(f"πŸ”— Updated backend reference: {old_qualifier} β†’ {qualifier}") - + def has_backend_table(self) -> bool: """ Check if this DataFrame has a backend table reference. - + Returns: True if DataFrame has a backend table, False if in-memory only """ return self._table_qualifier is not None - + def get_backend_info(self) -> dict[str, str | None]: """ Get information about the backend storage. - + Returns: Dictionary with backend information: - 'qualifier': Full table qualifier or None @@ -583,7 +749,7 @@ def get_backend_info(self) -> dict[str, str | None]: - 'dataset': Dataset name (if applicable) - 'table': Table name (if applicable) - 'type': 'backend' or 'memory' - + Example: info = handler.get_backend_info() # {'qualifier': 'myproject.sales.customers', @@ -594,36 +760,40 @@ def get_backend_info(self) -> dict[str, str | None]: """ if self._table_qualifier is None: return { - 'qualifier': None, - 'project': None, - 'dataset': None, - 'table': None, - 'type': 'memory' + "qualifier": None, + "project": None, + "dataset": None, + "table": None, + "type": "memory", } - + # Try to parse standard format: project.dataset.table - parts = self._table_qualifier.split('.') + parts = self._table_qualifier.split(".") info: dict[str, str | None] = { - 'qualifier': self._table_qualifier, - 'project': None, - 'dataset': None, - 'table': None, - 'type': 'backend' + "qualifier": self._table_qualifier, + "project": None, + "dataset": None, + "table": None, + "type": "backend", } - + if len(parts) == 3: - info['project'] = parts[0] - info['dataset'] = parts[1] - info['table'] = parts[2] + info["project"] = parts[0] + info["dataset"] = parts[1] + info["table"] = parts[2] elif len(parts) == 2: - info['dataset'] = parts[0] - info['table'] = parts[1] + info["dataset"] = parts[0] + info["table"] = parts[1] elif len(parts) == 1: - info['table'] = parts[0] - + info["table"] = parts[0] + return info def __repr__(self) -> str: num_extracted = len(self.nested_fields) - backend_info = " [in-memory]" if not self.has_backend_table() else f" [{self._table_qualifier}]" + backend_info = ( + " [in-memory]" + if not self.has_backend_table() + else f" [{self._table_qualifier}]" + ) return f"DataFrameHandler({len(self.original_columns)} cols β†’ {num_extracted} nested fields{backend_info})" diff --git a/leanframe/core/indexing.py b/leanframe/core/indexing.py new file mode 100644 index 0000000..84e9fbf --- /dev/null +++ b/leanframe/core/indexing.py @@ -0,0 +1,536 @@ +# Copyright 2025 Google LLC, LeanFrame Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Indexing support for leanframe DataFrames. + +This module provides pandas-like indexing with SQL semantics. Since BigQuery +and other SQL databases don't have persistent row ordering, we use explicit +ORDER BY clauses to establish deterministic ordering for subset selection. + +Key Design Principles: +- Single index only (no multi-index support) +- Explicit ordering specification via column(s) + sort direction +- .loc and .iloc style accessors that translate to SQL +- Integration with nested column handling +- Deferred execution (no materialization unless needed) + +Philosophy: + Pandas: df.iloc[0:10] assumes row order is intrinsic + Leanframe: df.set_index('timestamp', 'desc').iloc[0:10] makes ordering explicit + + This forces users to think about ordering, which is critical for + reproducible results in SQL databases without implicit row ordering. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from leanframe.core.frame import DataFrame + +import ibis.expr.types as ibis_types + + +class Index: + """ + Represents an ordering specification for a DataFrame. + + Unlike pandas Index which stores actual values, leanframe Index is a + specification for how to order rows deterministically. This allows + SQL-based .loc and .iloc operations. + + Design: + - Stores column name(s) and sort direction(s) + - Does NOT materialize data + - Used by Indexer classes to build ORDER BY clauses + - Supports single or multiple columns (composite ordering) + + Attributes: + columns: List of column names to order by (in priority order) + ascending: List of sort directions (True=ASC, False=DESC) for each column + name: Optional name for the index + + Example: + # Single column index + idx = Index('timestamp', ascending=False) + + # Multi-column index (ORDER BY priority DESC, timestamp DESC) + idx = Index(['priority', 'timestamp'], ascending=[False, False]) + + # With custom name + idx = Index('customer_id', ascending=True, name='customer_idx') + """ + + def __init__( + self, + columns: str | list[str], + ascending: bool | list[bool] = True, + name: str | None = None, + ): + """ + Initialize an Index specification. + + Args: + columns: Column name(s) to order by. Can be: + - Single string: 'timestamp' + - List of strings: ['priority', 'timestamp'] + ascending: Sort direction(s). Can be: + - Single bool: True (applies to all columns) + - List of bools: [False, True] (one per column) + name: Optional name for the index + + Raises: + ValueError: If ascending list length doesn't match columns list length + """ + # Normalize to lists + if isinstance(columns, str): + self.columns = [columns] + else: + self.columns = list(columns) + + # Normalize ascending to list + if isinstance(ascending, bool): + self.ascending = [ascending] * len(self.columns) + else: + self.ascending = list(ascending) + if len(self.ascending) != len(self.columns): + raise ValueError( + f"Length of ascending ({len(self.ascending)}) must match " + f"length of columns ({len(self.columns)})" + ) + + # Set name + if name is not None: + self.name = name + elif len(self.columns) == 1: + self.name = self.columns[0] + else: + self.name = f"({', '.join(self.columns)})" + + @property + def column(self) -> str: + """ + Get the primary (first) column name. + + For backward compatibility with single-column index usage. + + Returns: + First column in the index + """ + return self.columns[0] + + def is_multi_column(self) -> bool: + """Check if this is a multi-column index.""" + return len(self.columns) > 1 + + def __repr__(self) -> str: + if len(self.columns) == 1: + direction = "ascending" if self.ascending[0] else "descending" + return f"Index('{self.columns[0]}', {direction})" + else: + parts = [] + for col, asc in zip(self.columns, self.ascending): + direction = "ASC" if asc else "DESC" + parts.append(f"{col} {direction}") + return f"Index([{', '.join(parts)}])" + + def __str__(self) -> str: + parts = [] + for col, asc in zip(self.columns, self.ascending): + direction = "ASC" if asc else "DESC" + parts.append(f"{col} {direction}") + return ", ".join(parts) + + +class ILocIndexer: + """ + Position-based indexing with explicit ordering (like pandas .iloc). + + Since SQL databases don't have intrinsic row positions, we require + the DataFrame to have an explicit index (ORDER BY specification). + + Supported operations: + - df.iloc[5] - Single row by position + - df.iloc[5:10] - Slice of rows + - df.iloc[[1, 3, 5]] - Specific positions + - df.iloc[:10] - First N rows (with ordering) + - df.iloc[-10:] - Last N rows (with ordering) + + Example: + # Must set index first for deterministic ordering + df = df.set_index('timestamp', ascending=False) + + # Now position-based indexing works + first_row = df.iloc[0] # Newest record (timestamp DESC) + top_10 = df.iloc[0:10] # 10 newest records + last_row = df.iloc[-1] # Oldest record + """ + + def __init__(self, dataframe: DataFrame): + """ + Initialize iloc indexer. + + Args: + dataframe: Parent DataFrame + """ + self._df = dataframe + + def __getitem__(self, key): + """ + Get rows by position. + + Args: + key: int, slice, or list of ints + + Returns: + DataFrame (for slices/lists) or Series (for single int) + + Raises: + ValueError: If DataFrame has no index set + """ + # Check if index is set + if not hasattr(self._df, "_index") or self._df._index is None: + raise ValueError( + "Cannot use .iloc without an index. " + "Use .set_index('column_name') to establish ordering first.\n\n" + "Example: df.set_index('timestamp', ascending=False).iloc[0:10]" + ) + + index = self._df._index + ibis_table = self._df._data + + # Apply ordering based on index (supports multi-column) + order_exprs = [] + for col, asc in zip(index.columns, index.ascending): + if asc: + order_exprs.append(ibis_table[col]) + else: + order_exprs.append(ibis_table[col].desc()) + ordered = ibis_table.order_by(order_exprs) + + # Handle different key types + if isinstance(key, int): + # Single row - use limit + offset + if key < 0: + # Negative indexing - need to reverse order and count from end + # This is expensive in SQL but supported for pandas compatibility + import warnings + + warnings.warn( + "Negative indexing with .iloc requires counting all rows. " + "Consider using positive indices for better performance.", + UserWarning, + ) + # Reverse order and take abs(key) - 1 offset + # Reverse all ordering directions + reverse_exprs = [] + for col, asc in zip(index.columns, index.ascending): + if asc: + reverse_exprs.append(ibis_table[col].desc()) + else: + reverse_exprs.append(ibis_table[col]) + reversed_order = ibis_table.order_by(reverse_exprs) + result = reversed_order.limit(1, offset=abs(key) - 1) + else: + result = ordered.limit(1, offset=key) + + # Return Series for single row + from leanframe.core.frame import DataFrame + + temp_df = DataFrame(result) + # Convert single row to Series - use first column as example + # In practice, this returns a Series-like dict or the row + # For now, return DataFrame (pandas also returns Series here) + return temp_df + + elif isinstance(key, slice): + # Slice - convert to limit/offset + start = key.start or 0 + stop = key.stop + step = key.step + + if step is not None and step != 1: + raise NotImplementedError( + f"Step size {step} not supported. " + "SQL databases don't support stepping in result sets." + ) + + # Handle negative indices + if start < 0 or (stop is not None and stop < 0): + raise NotImplementedError( + "Negative indices in slices not yet supported. " + "Use positive indices: df.iloc[0:10]" + ) + + # Build SQL LIMIT/OFFSET + if stop is None: + # Open-ended slice: df.iloc[10:] + result = ordered.limit(None, offset=start) + else: + # Bounded slice: df.iloc[10:20] + limit = stop - start + result = ordered.limit(limit, offset=start) + + from leanframe.core.frame import DataFrame + + return DataFrame(result) + + elif isinstance(key, list): + # List of positions - need to use row_number() window function + # This is more complex in SQL + raise NotImplementedError( + "List indexing with .iloc not yet supported. " + "Use slices or single positions instead." + ) + + else: + raise TypeError( + f"Invalid index type: {type(key)}. Use int, slice, or list of ints." + ) + + +class LocIndexer: + """ + Label-based indexing (like pandas .loc). + + Since leanframe focuses on SQL semantics, .loc operates on the index + column values rather than traditional pandas labels. + + Supported operations: + - df.loc[value] - Rows where index column == value + - df.loc[value1:value2] - Range query on index column + - df.loc[[val1, val2]] - Multiple specific values + + Example: + # Set index on customer_id + df = df.set_index('customer_id') + + # Get customer with ID 12345 + customer = df.loc[12345] + + # Get customers in ID range + customers = df.loc[10000:20000] + + # Get specific customers + customers = df.loc[[12345, 67890, 11111]] + """ + + def __init__(self, dataframe: DataFrame): + """ + Initialize loc indexer. + + Args: + dataframe: Parent DataFrame + """ + self._df = dataframe + + def __getitem__(self, key): + """ + Get rows by index label. + + Args: + key: Single value, slice, or list of values + + Returns: + DataFrame (for slices/lists) or filtered result + + Raises: + ValueError: If DataFrame has no index set + """ + # Check if index is set + if not hasattr(self._df, "_index") or self._df._index is None: + raise ValueError( + "Cannot use .loc without an index. " + "Use .set_index('column_name') first.\n\n" + "Example: df.set_index('customer_id').loc[12345]" + ) + + index = self._df._index + ibis_table = self._df._data + + # For multi-column index, use the primary (first) column for filtering + # This is consistent with pandas behavior + index_col = ibis_table[index.columns[0]] + + # Handle different key types + if isinstance(key, slice): + # Range query: df.loc[start:stop] + start = key.start + stop = key.stop + + if start is None and stop is None: + # No filtering + result = ibis_table + elif start is None: + # df.loc[:stop] + result = ibis_table.filter(index_col <= stop) + elif stop is None: + # df.loc[start:] + result = ibis_table.filter(index_col >= start) + else: + # df.loc[start:stop] + result = ibis_table.filter((index_col >= start) & (index_col <= stop)) + + # Apply ordering + if index.ascending: + result = result.order_by(index_col) + else: + result = result.order_by(index_col.desc()) + + from leanframe.core.frame import DataFrame + + return DataFrame(result) + + elif isinstance(key, (list, tuple)): + # Multiple values: df.loc[[val1, val2, val3]] + result = ibis_table.filter(index_col.isin(key)) + + # Apply ordering (all index columns) + order_exprs = [] + for col, asc in zip(index.columns, index.ascending): + if asc: + order_exprs.append(result[col]) + else: + order_exprs.append(result[col].desc()) + result = result.order_by(order_exprs) + + from leanframe.core.frame import DataFrame + + return DataFrame(result) + + else: + # Single value: df.loc[value] + result = ibis_table.filter(index_col == key) + + from leanframe.core.frame import DataFrame + + return DataFrame(result) + + +class HeadTailMixin: + """ + Mixin providing .head() and .tail() methods. + + These are convenience methods that wrap .iloc with explicit ordering. + + This mixin expects to be mixed into a class that has: + - _data: ibis_types.Table (the underlying Ibis table) + - _index: Index | None (the ordering specification) + + Typically used with DataFrame class. + """ + + # Type hints for attributes that must exist in the mixed class + _data: ibis_types.Table + _index: Index | None + + def head(self, n: int = 5) -> DataFrame: + """ + Return first n rows based on index ordering. + + If no index is set, returns first n rows in arbitrary order + (database dependent). + + Args: + n: Number of rows to return (default: 5) + + Returns: + DataFrame with first n rows + + Example: + # With explicit ordering + df.set_index('timestamp', ascending=False).head(10) + + # Without index (arbitrary order) + df.head() # First 5 rows in database order + """ + ibis_table = self._data + + # Apply ordering if index is set (supports multi-column) + if hasattr(self, "_index") and self._index is not None: + order_exprs = [] + for col, asc in zip(self._index.columns, self._index.ascending): + if asc: + order_exprs.append(ibis_table[col]) + else: + order_exprs.append(ibis_table[col].desc()) + ibis_table = ibis_table.order_by(order_exprs) + + result = ibis_table.limit(n) + from leanframe.core.frame import DataFrame + + return DataFrame(result) + + def tail(self, n: int = 5) -> DataFrame: + """ + Return last n rows based on index ordering. + + Requires index to be set for deterministic results. + + Args: + n: Number of rows to return (default: 5) + + Returns: + DataFrame with last n rows + + Raises: + ValueError: If no index is set + + Example: + df.set_index('timestamp', ascending=False).tail(10) + """ + if not hasattr(self, "_index") or self._index is None: + raise ValueError( + "Cannot use .tail() without an index. " + "Use .set_index('column_name') to establish ordering first.\n\n" + "Example: df.set_index('timestamp').tail(10)" + ) + + ibis_table = self._data + + # Reverse the ordering to get last n rows (all columns) + reverse_exprs = [] + for col, asc in zip(self._index.columns, self._index.ascending): + if asc: + reverse_exprs.append(ibis_table[col].desc()) + else: + reverse_exprs.append(ibis_table[col]) + reversed_table = ibis_table.order_by(reverse_exprs) + + # Take n rows, then reverse back to original order + result = reversed_table.limit(n) + + # Re-apply original ordering + original_exprs = [] + for col, asc in zip(self._index.columns, self._index.ascending): + if asc: + original_exprs.append(result[col]) + else: + original_exprs.append(result[col].desc()) + result = result.order_by(original_exprs) + + from leanframe.core.frame import DataFrame + + return DataFrame(result) + + +# Export public API +__all__ = [ + "Index", + "ILocIndexer", + "LocIndexer", + "HeadTailMixin", +] diff --git a/leanframe/core/nested_handler.py b/leanframe/core/nested_handler.py index 0709236..181df2f 100644 --- a/leanframe/core/nested_handler.py +++ b/leanframe/core/nested_handler.py @@ -27,27 +27,27 @@ class NestedHandler: """ Orchestrator for managing multiple DataFrames with nested columns. - + This class: - Manages multiple DataFrameHandler instances (one per DataFrame) - Provides operations across DataFrames (joins, etc.) - Maintains relationships between DataFrames - Tracks DataFrames by name for easy reference - + Design Philosophy: - Each DataFrame gets its own DataFrameHandler (single responsibility) - NestedHandler coordinates operations between handlers - Handlers are created lazily when DataFrames are added - Results can be added back to the context for chaining operations - + Usage Pattern: # Create orchestrator handler = NestedHandler() - + # Add DataFrames (creates DataFrameHandler internally) handler.add("customers", customers_df) handler.add("orders", orders_df) - + # Perform operations using named references joined_df = handler.join_on_nested( left="customers", @@ -56,69 +56,69 @@ class NestedHandler: right_column="customer_email", how="inner" ) - + # Add result back for further operations handler.add("customer_orders", joined_df) - + Example Workflow: handler = NestedHandler() handler.add("customers", customers_df) handler.add("orders", orders_df) handler.add("regions", regions_df) - + # Chain operations handler.join_on_nested("customers", "regions", ...) handler.join_on_nested("customer_regions", "orders", ...) """ - + def __init__(self): """Initialize empty NestedHandler.""" # Maps name -> DataFrameHandler self._handlers: dict[str, DataFrameHandler] = {} - + # Optional: Track join relationships for lineage/debugging self._relationships: list[dict] = [] - + def add( - self, - name: str, - df: DataFrame, + self, + name: str, + df: DataFrame, max_depth: int = 10, - table_qualifier: str | None = None + table_qualifier: str | None = None, ) -> DataFrameHandler: """ Add a leanframe DataFrame to the handler context. - + This creates a DataFrameHandler for the DataFrame and stores it under the given name for later reference in operations. - + Args: name: Unique identifier for this DataFrame df: leanframe DataFrame to add max_depth: Maximum nesting depth to analyze (passed to DataFrameHandler) table_qualifier: Optional backend table identifier (e.g., "project.dataset.table") The handler will track this as its backend reference. - + Returns: The created DataFrameHandler (for direct access if needed) - + Raises: ValueError: If name already exists - + Example: handler = NestedHandler() - + # Without table qualifier (in-memory DataFrame) customer_handler = handler.add("customers", customers_df) print(customer_handler.table_qualifier) # None - + # With table qualifier for backend reference handler.add("orders", orders_df, table_qualifier="mydb.sales.orders") - + # Can access handler directly or via name print(customer_handler.nested_fields) print(handler.get("customers").nested_fields) # Same thing - + # Check backend status orders_handler = handler.get("orders") print(orders_handler.table_qualifier) # "mydb.sales.orders" @@ -129,48 +129,49 @@ def add( f"DataFrame '{name}' already exists. " f"Use remove('{name}') first or choose a different name." ) - + print(f"\nπŸ“¦ Adding DataFrame '{name}' to NestedHandler...") if table_qualifier: print(f" Backend table: {table_qualifier}") - + # Create handler with table qualifier - handler owns this reference now - df_handler = DataFrameHandler(df, max_depth=max_depth, table_qualifier=table_qualifier) + df_handler = DataFrameHandler( + df, max_depth=max_depth, table_qualifier=table_qualifier + ) self._handlers[name] = df_handler - - print(f"βœ… Added '{name}' with {len(df_handler.original_columns)} columns " - f"({len(df_handler.nested_fields)} nested fields discovered)") - + + print( + f"βœ… Added '{name}' with {len(df_handler.original_columns)} columns " + f"({len(df_handler.nested_fields)} nested fields discovered)" + ) + return df_handler - + def get(self, name: str) -> DataFrameHandler: """ Get a DataFrameHandler by name. - + Args: name: Name of the DataFrame - + Returns: DataFrameHandler for the named DataFrame - + Raises: KeyError: If name not found """ if name not in self._handlers: available = list(self._handlers.keys()) - raise KeyError( - f"DataFrame '{name}' not found. " - f"Available: {available}" - ) + raise KeyError(f"DataFrame '{name}' not found. Available: {available}") return self._handlers[name] - + def remove(self, name: str): """ Remove a DataFrame from the handler. - + Args: name: Name of the DataFrame to remove - + Raises: KeyError: If name not found """ @@ -178,22 +179,22 @@ def remove(self, name: str): raise KeyError(f"DataFrame '{name}' not found") del self._handlers[name] print(f"πŸ—‘οΈ Removed DataFrame '{name}' from NestedHandler") - + def list_dataframes(self) -> list[str]: """ List all DataFrame names in the handler. - + Returns: List of DataFrame names """ return list(self._handlers.keys()) - + def show_backend_status(self): """ Show backend table status for all DataFrames. - + Displays which DataFrames have backend tables and which are in-memory only. - + Example output: πŸ“Š Backend Status for 4 DataFrames: βœ… customers β†’ myproject.sales.customers @@ -207,11 +208,11 @@ def show_backend_status(self): print(f"βœ… {name} β†’ {handler.table_qualifier}") else: print(f"βšͺ {name} β†’ in-memory (no backend table)") - + def show_structure(self, name: str | None = None): """ Show structure of one or all DataFrames. - + Args: name: Specific DataFrame name, or None to show all """ @@ -223,50 +224,47 @@ def show_structure(self, name: str | None = None): for df_name in self._handlers.keys(): print(f"\n--- {df_name} ---") self._handlers[df_name].show_structure() - + # Data preparation - extract nested fields for operations - + def prepare( - self, - name: str, - fields: list[str] | None = None, - verbose: bool = False + self, name: str, fields: list[str] | None = None, verbose: bool = False ) -> DataFrame: """ Prepare a DataFrame by extracting specified nested fields. - + This is a preprocessing step before operations like joins. The handler analyzes nested structure and extracts fields, returning a flattened DataFrame ready for SQL-like operations. - + Args: name: Name of the DataFrame to prepare fields: List of nested paths to extract (e.g., ['profile.email', 'address.city']) If None, extracts all discovered nested fields verbose: Whether to print extraction details - + Returns: DataFrame with nested fields extracted as flat columns - + Example: # Prepare customers with specific fields customers_flat = handler.prepare( "customers", fields=['profile.contact.email', 'profile.name'] ) - + # Prepare with all nested fields orders_flat = handler.prepare("orders") - + # Now use prepared DataFrames with standard operations result = customers_flat.join(orders_flat, ...) - + Note: This does NOT modify the original DataFrame in the handler. It returns a new DataFrame with extracted fields. """ df_handler = self.get(name) - + if fields is None: # Extract all nested fields return df_handler.extract_nested_fields(verbose=verbose) @@ -278,86 +276,86 @@ def prepare( f"Path '{path}' not found in nested structure. " f"Available: {list(df_handler.extracted_fields.keys())}" ) - + # Extract all nested fields first extracted = df_handler.extract_nested_fields(verbose=verbose) - + # Build list of columns to keep: # 1. All original non-nested columns (keep regular columns) # 2. Only the requested nested fields (by their extracted names) needed_cols = [] - + # Check which original columns are top-level nested structs # nested_fields has paths like "profile.contact.email", not "profile" # So we need to check if a column is the root of any nested path nested_root_columns = set() for nested_path in df_handler.nested_fields.keys(): # Get the top-level column name (before first dot) - root = nested_path.split('.')[0] + root = nested_path.split(".")[0] nested_root_columns.add(root) - + # Add non-nested original columns (exclude nested struct columns) for col in df_handler.original_columns: if col not in nested_root_columns: needed_cols.append(col) - + # Add requested nested fields (by their extracted names) for path in fields: extracted_name = df_handler.extracted_fields[path] needed_cols.append(extracted_name) - + # Select only the needed columns return DataFrame(extracted._data.select(*needed_cols)) - + def join( self, tables: dict[str, str | list[str] | None], on: list[tuple[str, str, str, str]] | None = None, predicates: list | None = None, - how: str = "inner" + how: str = "inner", ) -> DataFrame: """ Convenience method for SQL-like joins on multiple tables. - + This is syntactic sugar over prepare() + Ibis operations. It: 1. Prepares DataFrames (extracts nested fields if specified) 2. Builds join predicates 3. Delegates to Ibis for execution 4. Returns result DataFrame - + For complex queries (WHERE, HAVING, windows), use prepare() + direct Ibis! - + Args: tables: Dict mapping alias -> DataFrame name or list of fields to extract - str value: Use DataFrame by name, extract all nested fields - list value: Extract only specified nested fields - + Examples: - {"c": "customers"} # All fields from customers - {"c": "customers", "o": "orders"} # All fields from both - {"c": ["profile.email", "name"]} # Only specific fields - + on: List of join conditions as (table1_alias, col1, table2_alias, col2) tuples Each tuple specifies: (left_table, left_column, right_table, right_column) - + Examples: - [("c", "customer_id", "o", "customer_id")] # Single condition - [("c", "email", "o", "customer_email"), # Multi-column join ("c", "region", "o", "region")] - + predicates: Alternative to 'on': provide raw Ibis predicates for complex conditions If provided, 'on' is ignored - + how: Join type - 'inner', 'left', 'right', 'outer', 'cross', 'semi', 'anti' All Ibis join types supported (default: 'inner') - + Returns: Joined DataFrame (leanframe.DataFrame wrapping Ibis table) - + Raises: ValueError: If tables dict is empty, aliases invalid, or join conditions malformed KeyError: If referenced DataFrames don't exist in handler - + Examples: # Simple two-table join result = handler.join( @@ -365,7 +363,7 @@ def join( on=[("c", "customer_id", "o", "customer_id")], how="inner" ) - + # Join with selective nested field extraction # Note: Use dot notation naturally - gets converted to underscores internally result = handler.join( @@ -376,7 +374,7 @@ def join( on=[("c", "profile.contact.email", "o", "customer.email")], # Dot notation OK! how="left" ) - + # Three-table join with multiple conditions result = handler.join( tables={"c": "customers", "o": "orders", "p": "products"}, @@ -386,42 +384,42 @@ def join( ], how="inner" ) - + # Cross join (no conditions needed) result = handler.join( tables={"c": "customers", "p": "products"}, how="cross" ) - + # Continue with Ibis operations on result filtered = result._data.filter(result._data.amount > 100) grouped = filtered.group_by("region").aggregate(total=filtered.amount.sum()) final = DataFrame(grouped) - + Note: For complex queries with WHERE, HAVING, window functions, etc., use prepare() directly and compose Ibis operations: - + customers = handler.prepare("customers") orders = handler.prepare("orders") - + # Full Ibis power available result = customers._data.join(orders._data, ...).filter(...).group_by(...) """ if not tables: raise ValueError("Must provide at least one table in 'tables' dict") - + if not on and not predicates and how != "cross": raise ValueError( "Must provide either 'on' conditions or 'predicates', " "or use how='cross' for cross join" ) - + print(f"\nπŸ”— Joining {len(tables)} table(s) using '{how}' join...") - + # Step 1: Prepare all tables (extract nested fields as needed) prepared_tables: dict[str, DataFrame] = {} - + for alias, spec in tables.items(): if isinstance(spec, str): # It's a DataFrame name - prepare with all fields @@ -443,24 +441,28 @@ def join( f"Invalid table spec for alias '{alias}': {type(spec)}. " f"Expected str (DataFrame name) or list (field paths)" ) - + # Step 2: Build the join chain # Start with the first table aliases = list(prepared_tables.keys()) if len(aliases) == 0: raise ValueError("No tables to join") - + # Get first table result_table = prepared_tables[aliases[0]]._data - print(f" βœ… Starting with '{aliases[0]}': {len(result_table.columns)} columns") - + print( + f" βœ… Starting with '{aliases[0]}': {len(result_table.columns)} columns" + ) + # Join with remaining tables sequentially for i in range(1, len(aliases)): right_alias = aliases[i] right_table = prepared_tables[right_alias]._data - - print(f" πŸ”— Joining with '{right_alias}': {len(right_table.columns)} columns") - + + print( + f" πŸ”— Joining with '{right_alias}': {len(right_table.columns)} columns" + ) + # Build predicates for this join if predicates is not None: # Use raw Ibis predicates (advanced usage) @@ -475,57 +477,59 @@ def join( f"Invalid join condition: {condition}. " f"Expected (table1_alias, col1, table2_alias, col2)" ) - + left_alias, left_col, right_alias_cond, right_col = condition - + # Check if this condition involves the current right table if right_alias_cond == right_alias: # Convert dot notation to underscore (user convenience) # User can write "profile.contact.email" and we convert to "profile_contact_email" left_col_normalized = left_col.replace(".", "_") right_col_normalized = right_col.replace(".", "_") - + # Build Ibis predicate # Need to access the correct table - tricky with chained joins # For now, use column name directly join_predicates.append( - (result_table[left_col_normalized], right_table[right_col_normalized]) + ( + result_table[left_col_normalized], + right_table[right_col_normalized], + ) ) else: join_predicates = [] - + # Perform the join if join_predicates: result_table = result_table.join( right_table, predicates=join_predicates, - how=how # type: ignore + how=how, # type: ignore ) else: # Cross join (no predicates) result_table = result_table.join( right_table, - how=how # type: ignore + how=how, # type: ignore ) - + result_df = DataFrame(result_table) print(f" βœ… Join complete: {len(result_df.columns)} total columns") - + return result_df - + # Legacy join methods - DEPRECATED # Use prepare() + direct Ibis operations instead for full SQL flexibility - def __repr__(self) -> str: count = len(self._handlers) names = ", ".join(self._handlers.keys()) if count > 0 else "empty" return f"NestedHandler({count} DataFrames: {names})" - + def __len__(self) -> int: """Return number of managed DataFrames.""" return len(self._handlers) - + def __contains__(self, name: str) -> bool: """Check if a DataFrame name exists in the handler.""" return name in self._handlers diff --git a/leanframe/core/session.py b/leanframe/core/session.py index 2f323de..7910dd9 100644 --- a/leanframe/core/session.py +++ b/leanframe/core/session.py @@ -43,8 +43,9 @@ def __init__(self, backend: ibis.BaseBackend | None): def read_sql_table(self, table_name: str): """Create a DataFrame pointing to the table called ``table_name``.""" import leanframe.core.frame - #TODO: will crash if self._backend is None. - return leanframe.core.frame.DataFrame(self._backend.table(table_name)) # type: ignore + + # TODO: will crash if self._backend is None. + return leanframe.core.frame.DataFrame(self._backend.table(table_name)) # type: ignore def read_ibis(self, table_expression: ibis_types.Table): """Create a DataFrame from an Ibis table expression.""" @@ -60,8 +61,8 @@ def DataFrame(self, data: ibis_types.Table | pandas.DataFrame): return leanframe.core.frame.DataFrame(data) elif isinstance(data, pandas.DataFrame): table_name = f"lf_{''.join(random.choices(_ALPHABET, k=10))}" - #TODO: will crash if self._backend is None. - table = self._backend.create_table(table_name, data, temp=True) # type: ignore + # TODO: will crash if self._backend is None. + table = self._backend.create_table(table_name, data, temp=True) # type: ignore return leanframe.core.frame.DataFrame(table) else: raise NotImplementedError( diff --git a/leanframe/docs/DYNAMIC_HANDLER_README.md b/leanframe/docs/DYNAMIC_HANDLER_README.md index 9f822c8..0724e3f 100644 --- a/leanframe/docs/DYNAMIC_HANDLER_README.md +++ b/leanframe/docs/DYNAMIC_HANDLER_README.md @@ -4,8 +4,8 @@ This implementation provides a **truly dynamic** nested DataFrame handler that can automatically introspect and work with any nested DataFrame structure in leanframe, eliminating the need for hardcoded schema-specific implementations. -**Location**: `leanframe.core.nested_handler.DynamicNestedHandler` -**Import**: `from leanframe import DynamicNestedHandler` +**Location**: `leanframe.core.nested_handler.DataFrameHandler` +**Import**: `from leanframe import DataFrameHandler` ## Key Features @@ -19,7 +19,7 @@ This implementation provides a **truly dynamic** nested DataFrame handler that c - **Dictionary-like Access**: `handler.get_column('nested_field')`, `'field' in handler`, `handler.keys()` - **Record Access**: `handler[0]` returns complete record as dictionary - **Column Operations**: Get entire columns as lists for analysis -- **Filtering**: `handler.filter_by('field', value)` with ibis expressions +- **Filtering**: `handler.filter_by(field=value)` with ibis expressions ### βœ… Performance & Memory Efficient - **Lazy Evaluation**: Only extracts data when accessed @@ -32,10 +32,10 @@ This implementation provides a **truly dynamic** nested DataFrame handler that c ### Basic Usage ```python # Import from leanframe -from leanframe import DynamicNestedHandler +from leanframe import DataFrameHandler # Or import from core module directly -from leanframe.core.nested_handler import DynamicNestedHandler +from leanframe.core.nested_handler import DataFrameHandler # For testing, use absolute imports from demos.utils.create_nested_data import create_simple_nested_dataframe @@ -44,7 +44,7 @@ from demos.utils.create_nested_data import create_simple_nested_dataframe df = create_simple_nested_dataframe() # Handler automatically adapts to structure -handler = DynamicNestedHandler(df) +handler = DataFrameHandler(df) # Access data naturally names = handler.get_column('person_name') # ['Alice', 'Bob', 'Charlie'] @@ -76,7 +76,7 @@ for field_name, field_data in handler.items(): ### Filtering ```python # Filter records -adults = handler.filter_by('person_age', 30) +adults = handler.filter_by(person_age=30) print(f"Found {len(adults)} records") ``` @@ -84,7 +84,7 @@ print(f"Found {len(adults)} records") ### Core Components -1. **`DynamicNestedHandler`** - Main class providing the interface +1. **`DataFrameHandler`** - Main class providing the interface 2. **`create_nested_data.py`** - Centralized utilities for creating test data 3. **Introspection Engine** - Automatically detects struct types and extracts field schemas 4. **Field Extraction** - Converts nested fields to flat columns using ibis expressions @@ -94,12 +94,12 @@ print(f"Found {len(adults)} records") ``` leanframe/ β”œβ”€β”€ core/ -β”‚ β”œβ”€β”€ nested_handler.py # THE DynamicNestedHandler implementation +β”‚ β”œβ”€β”€ nested_handler.py # THE DataFrameHandler implementation β”‚ β”œβ”€β”€ frame.py # DataFrame core β”‚ └── ...other core modules... β”œβ”€β”€ docs/ β”‚ └── DYNAMIC_HANDLER_README.md # This documentation -└── __init__.py # Exports DynamicNestedHandler +└── __init__.py # Exports DataFrameHandler tests/unit/ β”œβ”€β”€ nested_data/ # Nested data testing & examples @@ -169,7 +169,7 @@ tests/unit/ ## Conclusion -The `DynamicNestedHandler` successfully addresses your requirement for **"a dynamic class being able to handle arbitrary DataFrames including nested and non nested columns and even multiple nesting layers"**. +The `DataFrameHandler` successfully addresses your requirement for **"a dynamic class being able to handle arbitrary DataFrames including nested and non nested columns and even multiple nesting layers"**. It provides an intuitive, performant interface that automatically adapts to any nested DataFrame structure, making it easy to work with complex nested data in leanframe without requiring pandas or schema-specific implementations. diff --git a/tests/test_to_ibis.py b/tests/test_to_ibis.py index 8e9bfe9..9dcbb19 100644 --- a/tests/test_to_ibis.py +++ b/tests/test_to_ibis.py @@ -1,13 +1,15 @@ - import ibis import ibis.expr.types as ibis_types from leanframe.core.frame import DataFrame from leanframe.core.series import Series + def test_dataframe_to_ibis(): # Setup con = ibis.sqlite.connect() - t = con.create_table('test_df_ibis', schema=ibis.schema({'a': 'int64', 'b': 'string'})) + t = con.create_table( + "test_df_ibis", schema=ibis.schema({"a": "int64", "b": "string"}) + ) df = DataFrame(t) # Execute @@ -17,15 +19,16 @@ def test_dataframe_to_ibis(): assert isinstance(expr, ibis_types.Table) assert expr.equals(t) + def test_series_to_ibis(): # Setup con = ibis.sqlite.connect() - t = con.create_table('test_series_ibis', schema=ibis.schema({'a': 'int64'})) - s = Series(t['a']) + t = con.create_table("test_series_ibis", schema=ibis.schema({"a": "int64"})) + s = Series(t["a"]) # Execute expr = s.to_ibis() # Assert assert isinstance(expr, ibis_types.Column) - assert expr.equals(t['a']) + assert expr.equals(t["a"]) diff --git a/tests/unit/nested_data/test_dynamic_nested_handler.py b/tests/unit/nested_data/test_dynamic_nested_handler.py index 6cca862..449425b 100644 --- a/tests/unit/nested_data/test_dynamic_nested_handler.py +++ b/tests/unit/nested_data/test_dynamic_nested_handler.py @@ -11,7 +11,11 @@ - Works with arbitrary nesting levels """ -from demos.utils.create_nested_data import create_simple_nested_dataframe, create_extended_nested_dataframe, create_deeply_nested_dataframe +from demos.utils.create_nested_data import ( + create_simple_nested_dataframe, + create_extended_nested_dataframe, + create_deeply_nested_dataframe, +) from leanframe.core.frame import DataFrameHandler @@ -83,11 +87,11 @@ def test_filtering(): # Extract the flattened DataFrame extracted_df = handler.extract_nested_fields(verbose=False) - + # Filter using pandas (simpler for testing) filtered_pandas = extracted_df.to_pandas() filtered_by_age = filtered_pandas[filtered_pandas["person_age"] == 30] - + # Test the filtered results if len(filtered_by_age) > 0: assert all(filtered_by_age["person_age"] == 30) diff --git a/tests/unit/nested_data/test_join_convenience.py b/tests/unit/nested_data/test_join_convenience.py index 13d6287..feb13aa 100644 --- a/tests/unit/nested_data/test_join_convenience.py +++ b/tests/unit/nested_data/test_join_convenience.py @@ -17,149 +17,168 @@ def duckdb_backend(): @pytest.fixture def sample_data(duckdb_backend): """Create sample test data with nested and flat tables. - + IMPORTANT: All tables use the SAME backend connection to ensure they can be joined within a single NestedHandler. """ # Customers with nested profile - customers_data = pd.DataFrame({ - 'customer_id': [1, 2, 3], - 'profile': [ - {'name': 'Alice', 'contact': {'email': 'alice@example.com', 'phone': '555-0001'}}, - {'name': 'Bob', 'contact': {'email': 'bob@example.com', 'phone': '555-0002'}}, - {'name': 'Charlie', 'contact': {'email': 'charlie@example.com', 'phone': '555-0003'}}, - ] - }) - + customers_data = pd.DataFrame( + { + "customer_id": [1, 2, 3], + "profile": [ + { + "name": "Alice", + "contact": {"email": "alice@example.com", "phone": "555-0001"}, + }, + { + "name": "Bob", + "contact": {"email": "bob@example.com", "phone": "555-0002"}, + }, + { + "name": "Charlie", + "contact": {"email": "charlie@example.com", "phone": "555-0003"}, + }, + ], + } + ) + # Orders (flat) - orders_data = pd.DataFrame({ - 'order_id': [101, 102, 103, 104], - 'customer_id': [1, 2, 1, 3], - 'amount': [100.0, 200.0, 150.0, 75.0] - }) - + orders_data = pd.DataFrame( + { + "order_id": [101, 102, 103, 104], + "customer_id": [1, 2, 1, 3], + "amount": [100.0, 200.0, 150.0, 75.0], + } + ) + # Products (flat) - products_data = pd.DataFrame({ - 'product_id': [1, 2, 3], - 'name': ['Widget', 'Gadget', 'Doohickey'], - 'price': [29.99, 149.99, 9.99] - }) - + products_data = pd.DataFrame( + { + "product_id": [1, 2, 3], + "name": ["Widget", "Gadget", "Doohickey"], + "price": [29.99, 149.99, 9.99], + } + ) + # Create all tables on the SAME backend - customers_table = duckdb_backend.create_table('test_customers', customers_data, temp=True) - orders_table = duckdb_backend.create_table('test_orders', orders_data, temp=True) - products_table = duckdb_backend.create_table('test_products', products_data, temp=True) - + customers_table = duckdb_backend.create_table( + "test_customers", customers_data, temp=True + ) + orders_table = duckdb_backend.create_table("test_orders", orders_data, temp=True) + products_table = duckdb_backend.create_table( + "test_products", products_data, temp=True + ) + return { - 'backend': duckdb_backend, # Include backend for tests that need it - 'customers': DataFrame(customers_table), - 'orders': DataFrame(orders_table), - 'products': DataFrame(products_table) + "backend": duckdb_backend, # Include backend for tests that need it + "customers": DataFrame(customers_table), + "orders": DataFrame(orders_table), + "products": DataFrame(products_table), } def test_join_two_tables_simple(sample_data): """Test simple two-table inner join.""" handler = NestedHandler() - handler.add("customers", sample_data['customers']) - handler.add("orders", sample_data['orders']) - + handler.add("customers", sample_data["customers"]) + handler.add("orders", sample_data["orders"]) + # Join on customer_id result = handler.join( tables={"c": "customers", "o": "orders"}, on=[("c", "customer_id", "o", "customer_id")], - how="inner" + how="inner", ) - + # Verify result result_pd = result.to_pandas() assert len(result_pd) == 4 # 4 orders - assert 'customer_id' in result_pd.columns - assert 'order_id' in result_pd.columns - assert 'amount' in result_pd.columns - assert 'profile_name' in result_pd.columns # Nested field extracted - assert 'profile_contact_email' in result_pd.columns + assert "customer_id" in result_pd.columns + assert "order_id" in result_pd.columns + assert "amount" in result_pd.columns + assert "profile_name" in result_pd.columns # Nested field extracted + assert "profile_contact_email" in result_pd.columns def test_join_handles_nested_extraction(sample_data): """Test that join automatically extracts nested fields.""" handler = NestedHandler() - handler.add("customers", sample_data['customers']) - handler.add("orders", sample_data['orders']) - + handler.add("customers", sample_data["customers"]) + handler.add("orders", sample_data["orders"]) + result = handler.join( tables={"c": "customers", "o": "orders"}, on=[("c", "customer_id", "o", "customer_id")], - how="left" + how="left", ) - + columns = result.columns - + # Check nested fields were extracted - assert 'profile_name' in columns - assert 'profile_contact_email' in columns - assert 'profile_contact_phone' in columns - + assert "profile_name" in columns + assert "profile_contact_email" in columns + assert "profile_contact_phone" in columns + # Check original nested column is gone - assert 'profile' not in columns + assert "profile" not in columns def test_join_three_tables(sample_data): """Test three-table join.""" # Add product_id to orders for this test - orders_pd = sample_data['orders'].to_pandas() - orders_pd['product_id'] = [1, 2, 1, 3] - + orders_pd = sample_data["orders"].to_pandas() + orders_pd["product_id"] = [1, 2, 1, 3] + handler = NestedHandler() - handler.add("customers", sample_data['customers']) - + handler.add("customers", sample_data["customers"]) + # CRITICAL: Use the SAME backend from sample_data fixture # All DataFrames in one NestedHandler must share the same backend! - backend = sample_data['backend'] - orders_table = backend.create_table('orders_with_products', orders_pd, temp=True) + backend = sample_data["backend"] + orders_table = backend.create_table("orders_with_products", orders_pd, temp=True) handler.add("orders", DataFrame(orders_table)) - handler.add("products", sample_data['products']) - + handler.add("products", sample_data["products"]) + # Three-table join result = handler.join( tables={"c": "customers", "o": "orders", "p": "products"}, on=[ ("c", "customer_id", "o", "customer_id"), - ("o", "product_id", "p", "product_id") + ("o", "product_id", "p", "product_id"), ], - how="inner" + how="inner", ) - + # Verify all tables are joined result_pd = result.to_pandas() - assert 'customer_id' in result_pd.columns - assert 'order_id' in result_pd.columns - assert 'product_id' in result_pd.columns - assert 'profile_name' in result_pd.columns - assert 'name' in result_pd.columns # Product name - assert 'price' in result_pd.columns + assert "customer_id" in result_pd.columns + assert "order_id" in result_pd.columns + assert "product_id" in result_pd.columns + assert "profile_name" in result_pd.columns + assert "name" in result_pd.columns # Product name + assert "price" in result_pd.columns def test_join_different_types(sample_data): """Test different join types.""" handler = NestedHandler() - handler.add("customers", sample_data['customers']) - handler.add("orders", sample_data['orders']) - + handler.add("customers", sample_data["customers"]) + handler.add("orders", sample_data["orders"]) + # Inner join inner = handler.join( tables={"c": "customers", "o": "orders"}, on=[("c", "customer_id", "o", "customer_id")], - how="inner" + how="inner", ) inner_pd = inner.to_pandas() assert len(inner_pd) == 4 # Only customers with orders - + # Left join - all customers left = handler.join( tables={"c": "customers", "o": "orders"}, on=[("c", "customer_id", "o", "customer_id")], - how="left" + how="left", ) left_pd = left.to_pandas() # Should include all customers (3) Γ— their orders @@ -170,8 +189,8 @@ def test_join_different_types(sample_data): def test_join_empty_tables_dict_raises_error(sample_data): """Test that empty tables dict raises error.""" handler = NestedHandler() - handler.add("customers", sample_data['customers']) - + handler.add("customers", sample_data["customers"]) + with pytest.raises(ValueError, match="Must provide at least one table"): handler.join(tables={}, on=[], how="inner") @@ -179,85 +198,75 @@ def test_join_empty_tables_dict_raises_error(sample_data): def test_join_missing_conditions_raises_error(sample_data): """Test that missing join conditions raises error for non-cross joins.""" handler = NestedHandler() - handler.add("customers", sample_data['customers']) - handler.add("orders", sample_data['orders']) - + handler.add("customers", sample_data["customers"]) + handler.add("orders", sample_data["orders"]) + with pytest.raises(ValueError, match="Must provide either"): - handler.join( - tables={"c": "customers", "o": "orders"}, - how="inner" - ) + handler.join(tables={"c": "customers", "o": "orders"}, how="inner") def test_join_nonexistent_dataframe_raises_error(sample_data): """Test that referencing nonexistent DataFrame raises error.""" handler = NestedHandler() - handler.add("customers", sample_data['customers']) - + handler.add("customers", sample_data["customers"]) + with pytest.raises(KeyError, match="not found"): handler.join( tables={"c": "customers", "o": "nonexistent"}, on=[("c", "customer_id", "o", "customer_id")], - how="inner" + how="inner", ) def test_join_result_can_be_further_processed(sample_data): """Test that join result can be used for further Ibis operations.""" handler = NestedHandler() - handler.add("customers", sample_data['customers']) - handler.add("orders", sample_data['orders']) - + handler.add("customers", sample_data["customers"]) + handler.add("orders", sample_data["orders"]) + # Join result = handler.join( tables={"c": "customers", "o": "orders"}, on=[("c", "customer_id", "o", "customer_id")], - how="inner" + how="inner", ) - + # Continue with Ibis operations filtered = result._data.filter(result._data.amount > 100) filtered_df = DataFrame(filtered) - + filtered_pd = filtered_df.to_pandas() assert len(filtered_pd) == 2 # Only 2 orders > 100 - assert all(filtered_pd['amount'] > 100) + assert all(filtered_pd["amount"] > 100) def test_join_multi_column_conditions(sample_data): """Test join with multiple column conditions.""" # CRITICAL: Use the SAME backend from sample_data fixture - backend = sample_data['backend'] - - table1_data = pd.DataFrame({ - 'id': [1, 2, 3], - 'region': ['US', 'EU', 'US'], - 'value': [10, 20, 30] - }) - - table2_data = pd.DataFrame({ - 'id': [1, 2, 1], - 'region': ['US', 'EU', 'EU'], - 'amount': [100, 200, 150] - }) - - table1 = backend.create_table('t1', table1_data, temp=True) - table2 = backend.create_table('t2', table2_data, temp=True) - + backend = sample_data["backend"] + + table1_data = pd.DataFrame( + {"id": [1, 2, 3], "region": ["US", "EU", "US"], "value": [10, 20, 30]} + ) + + table2_data = pd.DataFrame( + {"id": [1, 2, 1], "region": ["US", "EU", "EU"], "amount": [100, 200, 150]} + ) + + table1 = backend.create_table("t1", table1_data, temp=True) + table2 = backend.create_table("t2", table2_data, temp=True) + handler = NestedHandler() handler.add("t1", DataFrame(table1)) handler.add("t2", DataFrame(table2)) - + # Multi-column join result = handler.join( tables={"a": "t1", "b": "t2"}, - on=[ - ("a", "id", "b", "id"), - ("a", "region", "b", "region") - ], - how="inner" + on=[("a", "id", "b", "id"), ("a", "region", "b", "region")], + how="inner", ) - + result_pd = result.to_pandas() # Should only match rows where BOTH id AND region match assert len(result_pd) == 2 # (1, US) and (2, EU) @@ -266,15 +275,12 @@ def test_join_multi_column_conditions(sample_data): def test_join_cross_join(sample_data): """Test cross join (Cartesian product).""" handler = NestedHandler() - handler.add("customers", sample_data['customers']) - handler.add("products", sample_data['products']) - + handler.add("customers", sample_data["customers"]) + handler.add("products", sample_data["products"]) + # Cross join - no conditions needed - result = handler.join( - tables={"c": "customers", "p": "products"}, - how="cross" - ) - + result = handler.join(tables={"c": "customers", "p": "products"}, how="cross") + result_pd = result.to_pandas() # 3 customers Γ— 3 products = 9 rows assert len(result_pd) == 9 diff --git a/tests/unit/nested_data/test_prepare_method.py b/tests/unit/nested_data/test_prepare_method.py index 4b641a5..894319e 100644 --- a/tests/unit/nested_data/test_prepare_method.py +++ b/tests/unit/nested_data/test_prepare_method.py @@ -17,16 +17,27 @@ def duckdb_backend(): @pytest.fixture def nested_customers_df(duckdb_backend): """Create a DataFrame with nested customer data.""" - data = pd.DataFrame({ - 'customer_id': [1, 2, 3], - 'profile': [ - {'name': 'Alice', 'contact': {'email': 'alice@example.com', 'phone': '555-0001'}}, - {'name': 'Bob', 'contact': {'email': 'bob@example.com', 'phone': '555-0002'}}, - {'name': 'Charlie', 'contact': {'email': 'charlie@example.com', 'phone': '555-0003'}}, - ] - }) - - table = duckdb_backend.create_table('test_customers', data, temp=True) + data = pd.DataFrame( + { + "customer_id": [1, 2, 3], + "profile": [ + { + "name": "Alice", + "contact": {"email": "alice@example.com", "phone": "555-0001"}, + }, + { + "name": "Bob", + "contact": {"email": "bob@example.com", "phone": "555-0002"}, + }, + { + "name": "Charlie", + "contact": {"email": "charlie@example.com", "phone": "555-0003"}, + }, + ], + } + ) + + table = duckdb_backend.create_table("test_customers", data, temp=True) return DataFrame(table) @@ -34,58 +45,57 @@ def test_prepare_extracts_all_nested_fields(nested_customers_df): """Test that prepare() extracts all nested fields by default.""" handler = NestedHandler() handler.add("customers", nested_customers_df) - + # Prepare should extract all nested fields prepared = handler.prepare("customers") - + # Check that nested fields were extracted columns = prepared.columns - assert 'customer_id' in columns # Original flat column - assert 'profile_name' in columns # Extracted from profile.name - assert 'profile_contact_email' in columns # Extracted from profile.contact.email - assert 'profile_contact_phone' in columns # Extracted from profile.contact.phone - + assert "customer_id" in columns # Original flat column + assert "profile_name" in columns # Extracted from profile.name + assert "profile_contact_email" in columns # Extracted from profile.contact.email + assert "profile_contact_phone" in columns # Extracted from profile.contact.phone + # Original nested column should not be in prepared DataFrame - assert 'profile' not in columns + assert "profile" not in columns def test_prepare_with_specific_fields(nested_customers_df): """Test that prepare() can extract specific fields only.""" handler = NestedHandler() handler.add("customers", nested_customers_df) - + # Prepare with specific fields prepared = handler.prepare( - "customers", - fields=['profile.contact.email', 'profile.name'] + "customers", fields=["profile.contact.email", "profile.name"] ) - + columns = prepared.columns - + # Should have requested fields - assert 'profile_contact_email' in columns - assert 'profile_name' in columns - + assert "profile_contact_email" in columns + assert "profile_name" in columns + # Should have original flat columns - assert 'customer_id' in columns - + assert "customer_id" in columns + # Should NOT have unrequested nested fields - assert 'profile_contact_phone' not in columns + assert "profile_contact_phone" not in columns def test_prepare_nonexistent_field_raises_error(nested_customers_df): """Test that prepare() raises error for nonexistent fields.""" handler = NestedHandler() handler.add("customers", nested_customers_df) - + with pytest.raises(ValueError, match="not found in nested structure"): - handler.prepare("customers", fields=['profile.nonexistent.field']) + handler.prepare("customers", fields=["profile.nonexistent.field"]) def test_prepare_nonexistent_dataframe_raises_error(): """Test that prepare() raises error for nonexistent DataFrame.""" handler = NestedHandler() - + with pytest.raises(KeyError, match="not found"): handler.prepare("nonexistent") @@ -94,82 +104,87 @@ def test_prepare_does_not_modify_original(nested_customers_df): """Test that prepare() doesn't modify the original DataFrame in handler.""" handler = NestedHandler() handler.add("customers", nested_customers_df) - + # Get original handler original_handler = handler.get("customers") original_columns = original_handler.original_columns - + # Prepare prepared = handler.prepare("customers") - + # Original should be unchanged assert original_handler.original_columns == original_columns - assert 'profile' in original_handler.original_columns - + assert "profile" in original_handler.original_columns + # Prepared should be different - assert 'profile' not in prepared.columns + assert "profile" not in prepared.columns def test_prepare_enables_direct_ibis_joins(nested_customers_df, duckdb_backend): """Test that prepared DataFrames can be joined using direct Ibis operations.""" # Create orders DataFrame - orders_data = pd.DataFrame({ - 'order_id': [101, 102, 103], - 'customer_email': ['alice@example.com', 'bob@example.com', 'alice@example.com'], - 'amount': [100.0, 200.0, 150.0] - }) - orders_table = duckdb_backend.create_table('test_orders', orders_data, temp=True) + orders_data = pd.DataFrame( + { + "order_id": [101, 102, 103], + "customer_email": [ + "alice@example.com", + "bob@example.com", + "alice@example.com", + ], + "amount": [100.0, 200.0, 150.0], + } + ) + orders_table = duckdb_backend.create_table("test_orders", orders_data, temp=True) orders_df = DataFrame(orders_table) - + # Add both to handler handler = NestedHandler() handler.add("customers", nested_customers_df) handler.add("orders", orders_df) - + # Prepare both customers_flat = handler.prepare("customers") orders_flat = handler.prepare("orders") - + # Join using direct Ibis operations joined = customers_flat._data.join( orders_flat._data, predicates=[ - customers_flat._data.profile_contact_email == orders_flat._data.customer_email + customers_flat._data.profile_contact_email + == orders_flat._data.customer_email ], - how="inner" + how="inner", ) - + result = DataFrame(joined) - + # Verify join worked result_pd = result.to_pandas() assert len(result_pd) == 3 # 3 orders - assert 'customer_id' in result_pd.columns - assert 'order_id' in result_pd.columns - assert 'amount' in result_pd.columns - assert 'profile_contact_email' in result_pd.columns + assert "customer_id" in result_pd.columns + assert "order_id" in result_pd.columns + assert "amount" in result_pd.columns + assert "profile_contact_email" in result_pd.columns def test_prepare_with_flat_dataframe(duckdb_backend): """Test that prepare() works with already-flat DataFrames.""" # Create flat DataFrame (no nested columns) - data = pd.DataFrame({ - 'id': [1, 2, 3], - 'name': ['Alice', 'Bob', 'Charlie'], - 'age': [25, 30, 35] - }) - - table = duckdb_backend.create_table('test_flat', data, temp=True) + data = pd.DataFrame( + {"id": [1, 2, 3], "name": ["Alice", "Bob", "Charlie"], "age": [25, 30, 35]} + ) + + table = duckdb_backend.create_table("test_flat", data, temp=True) flat_df = DataFrame(table) - + handler = NestedHandler() handler.add("flat", flat_df) - + # Prepare should work even without nested fields prepared = handler.prepare("flat") - + # Should have all original columns columns = prepared.columns - assert 'id' in columns - assert 'name' in columns - assert 'age' in columns + assert "id" in columns + assert "name" in columns + assert "age" in columns diff --git a/tests/unit/nested_data/test_pure_leanframe_nested.py b/tests/unit/nested_data/test_pure_leanframe_nested.py index 1e87de0..9c2e1e8 100644 --- a/tests/unit/nested_data/test_pure_leanframe_nested.py +++ b/tests/unit/nested_data/test_pure_leanframe_nested.py @@ -139,7 +139,7 @@ def test_columnar_data_access(): table = pyarrow_result if table.num_rows > 0: - assert(1==2) + assert 1 == 2 names = table.column("person_name").to_pylist() ages = table.column("person_age").to_pylist() emails = table.column("email").to_pylist() diff --git a/tests/unit/session/test_col.py b/tests/unit/session/test_col.py index 79a0187..1959670 100644 --- a/tests/unit/session/test_col.py +++ b/tests/unit/session/test_col.py @@ -28,12 +28,12 @@ def test_col_assign_with_memtable(): session = Session(backend) # Create data - data = pd.DataFrame({'a': [1, 2, 3]}) + data = pd.DataFrame({"a": [1, 2, 3]}) t = ibis.memtable(data) df = session.DataFrame(t) # Use col to create a new column based on existing one - deferred_col = session.col('a') + deferred_col = session.col("a") # Verify it returns an Expression assert isinstance(deferred_col, leanframe.core.expression.Expression) @@ -46,7 +46,7 @@ def test_col_assign_with_memtable(): df_new = df.assign(b=expr) result = df_new.to_pandas() - expected = pd.DataFrame({'a': [1, 2, 3], 'b': [2, 3, 4]}) + expected = pd.DataFrame({"a": [1, 2, 3], "b": [2, 3, 4]}) pd.testing.assert_frame_equal(result, expected, check_dtype=False) @@ -55,18 +55,18 @@ def test_col_arithmetic_chain(): backend = ibis.sqlite.connect() session = Session(backend) - col_a = session.col('a') - col_b = session.col('b') + col_a = session.col("a") + col_b = session.col("b") # (a + b) * 2 expr = (col_a + col_b) * 2 - data = pd.DataFrame({'a': [10], 'b': [5]}) + data = pd.DataFrame({"a": [10], "b": [5]}) t = ibis.memtable(data) df = session.DataFrame(t) df_new = df.assign(c=expr) result = df_new.to_pandas() - expected = pd.DataFrame({'a': [10], 'b': [5], 'c': [30]}) + expected = pd.DataFrame({"a": [10], "b": [5], "c": [30]}) pd.testing.assert_frame_equal(result, expected, check_dtype=False) diff --git a/tests/unit/session/test_read_ibis.py b/tests/unit/session/test_read_ibis.py index 4454b21..f59d788 100644 --- a/tests/unit/session/test_read_ibis.py +++ b/tests/unit/session/test_read_ibis.py @@ -1,4 +1,3 @@ - import ibis import pandas as pd import pytest @@ -6,14 +5,16 @@ from leanframe.core import session + @pytest.fixture def mock_backend(): return MagicMock() + def test_read_ibis_returns_dataframe(mock_backend): """Test that read_ibis returns a leanframe DataFrame.""" s = session.Session(mock_backend) - df = pd.DataFrame({'a': [1, 2, 3]}) + df = pd.DataFrame({"a": [1, 2, 3]}) t = ibis.memtable(df) lf_df = s.read_ibis(t) @@ -22,20 +23,23 @@ def test_read_ibis_returns_dataframe(mock_backend): # Note: importing DataFrame inside the function to avoid circular imports if any, # though it should be fine at module level for type checking from leanframe.core.frame import DataFrame + assert isinstance(lf_df, DataFrame) # Check that the underlying data is correct assert lf_df.to_ibis().equals(t) + def test_read_ibis_with_complex_expression(mock_backend): """Test read_ibis with a more complex ibis expression.""" s = session.Session(mock_backend) - df = pd.DataFrame({'a': [1, 2, 3], 'b': [4, 5, 6]}) + df = pd.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]}) t = ibis.memtable(df) expr = t.mutate(c=t.a + t.b) lf_df = s.read_ibis(expr) from leanframe.core.frame import DataFrame + assert isinstance(lf_df, DataFrame) assert lf_df.to_ibis().equals(expr) diff --git a/tests/unit/test_expression.py b/tests/unit/test_expression.py index cfb3f4a..7a5bace 100644 --- a/tests/unit/test_expression.py +++ b/tests/unit/test_expression.py @@ -20,134 +20,160 @@ import pytest from leanframe import Session, col + @pytest.fixture def session(): """Return a Session connected to an in-memory duckdb.""" return Session(ibis.duckdb.connect()) + @pytest.fixture def df(session): """Return a test DataFrame.""" data = { - 'a': [1, -2, 3, -4, 5], - 'b': [5, 4, 3, 2, 1], + "a": [1, -2, 3, -4, 5], + "b": [5, 4, 3, 2, 1], } return session.read_ibis(ibis.memtable(data)) + def test_expression_comparison_methods(df): result = df.assign( - lt=col('a').lt(col('b')), - gt=col('a').gt(col('b')), - le=col('a').le(col('b')), - ge=col('a').ge(col('b')), - ne=col('a').ne(col('b')), - eq=col('a').eq(col('b')), + lt=col("a").lt(col("b")), + gt=col("a").gt(col("b")), + le=col("a").le(col("b")), + ge=col("a").ge(col("b")), + ne=col("a").ne(col("b")), + eq=col("a").eq(col("b")), ).to_pandas() pd.testing.assert_series_equal( - result['lt'], pd.Series([True, True, False, True, False], name='lt'), check_dtype=False + result["lt"], + pd.Series([True, True, False, True, False], name="lt"), + check_dtype=False, ) pd.testing.assert_series_equal( - result['gt'], pd.Series([False, False, False, False, True], name='gt'), check_dtype=False + result["gt"], + pd.Series([False, False, False, False, True], name="gt"), + check_dtype=False, ) pd.testing.assert_series_equal( - result['le'], pd.Series([True, True, True, True, False], name='le'), check_dtype=False + result["le"], + pd.Series([True, True, True, True, False], name="le"), + check_dtype=False, ) pd.testing.assert_series_equal( - result['ge'], pd.Series([False, False, True, False, True], name='ge'), check_dtype=False + result["ge"], + pd.Series([False, False, True, False, True], name="ge"), + check_dtype=False, ) pd.testing.assert_series_equal( - result['ne'], pd.Series([True, True, False, True, True], name='ne'), check_dtype=False + result["ne"], + pd.Series([True, True, False, True, True], name="ne"), + check_dtype=False, ) pd.testing.assert_series_equal( - result['eq'], pd.Series([False, False, True, False, False], name='eq'), check_dtype=False + result["eq"], + pd.Series([False, False, True, False, False], name="eq"), + check_dtype=False, ) + def test_expression_math_methods(session): - df = session.read_ibis(ibis.memtable({'a': [1.5, -2.1, 3.8]})) + df = session.read_ibis(ibis.memtable({"a": [1.5, -2.1, 3.8]})) result = df.assign( - r=round(col('a')), - r1=round(col('a'), 1), - abs=col('a').abs() + r=round(col("a")), r1=round(col("a"), 1), abs=col("a").abs() ).to_pandas() pd.testing.assert_series_equal( - result['r'], pd.Series([2.0, -2.0, 4.0], name='r'), check_dtype=False + result["r"], pd.Series([2.0, -2.0, 4.0], name="r"), check_dtype=False ) pd.testing.assert_series_equal( - result['r1'], pd.Series([1.5, -2.1, 3.8], name='r1'), check_dtype=False + result["r1"], pd.Series([1.5, -2.1, 3.8], name="r1"), check_dtype=False ) pd.testing.assert_series_equal( - result['abs'], pd.Series([1.5, 2.1, 3.8], name='abs'), check_dtype=False + result["abs"], pd.Series([1.5, 2.1, 3.8], name="abs"), check_dtype=False ) + def test_expression_aggregation_methods(session): - df = session.read_ibis(ibis.memtable({'a': [1, 2, 3], 'b': [True, False, True]})) + df = session.read_ibis(ibis.memtable({"a": [1, 2, 3], "b": [True, False, True]})) result = df.assign( - all_b=col('b').all(), - any_b=col('b').any(), - sum_a=col('a').sum(), - mean_a=col('a').mean(), - min_a=col('a').min(), - max_a=col('a').max(), - count_a=col('a').count() + all_b=col("b").all(), + any_b=col("b").any(), + sum_a=col("a").sum(), + mean_a=col("a").mean(), + min_a=col("a").min(), + max_a=col("a").max(), + count_a=col("a").count(), ).to_pandas() pd.testing.assert_series_equal( - result['all_b'], pd.Series([False, False, False], name='all_b'), check_dtype=False + result["all_b"], + pd.Series([False, False, False], name="all_b"), + check_dtype=False, ) pd.testing.assert_series_equal( - result['any_b'], pd.Series([True, True, True], name='any_b'), check_dtype=False + result["any_b"], pd.Series([True, True, True], name="any_b"), check_dtype=False ) pd.testing.assert_series_equal( - result['sum_a'], pd.Series([6, 6, 6], name='sum_a'), check_dtype=False + result["sum_a"], pd.Series([6, 6, 6], name="sum_a"), check_dtype=False ) pd.testing.assert_series_equal( - result['mean_a'], pd.Series([2.0, 2.0, 2.0], name='mean_a'), check_dtype=False + result["mean_a"], pd.Series([2.0, 2.0, 2.0], name="mean_a"), check_dtype=False ) pd.testing.assert_series_equal( - result['min_a'], pd.Series([1, 1, 1], name='min_a'), check_dtype=False + result["min_a"], pd.Series([1, 1, 1], name="min_a"), check_dtype=False ) pd.testing.assert_series_equal( - result['max_a'], pd.Series([3, 3, 3], name='max_a'), check_dtype=False + result["max_a"], pd.Series([3, 3, 3], name="max_a"), check_dtype=False ) + def test_expression_cumulative_methods(session): - df = session.read_ibis(ibis.memtable({'a': [1, 2, 3]})) + df = session.read_ibis(ibis.memtable({"a": [1, 2, 3]})) result = df.assign( - cummax=col('a').cummax(), - cummin=col('a').cummin(), - cumsum=col('a').cumsum(), - cumprod=col('a').cumprod(), - diff=col('a').diff() + cummax=col("a").cummax(), + cummin=col("a").cummin(), + cumsum=col("a").cumsum(), + cumprod=col("a").cumprod(), + diff=col("a").diff(), ).to_pandas() pd.testing.assert_series_equal( - result['cummax'], pd.Series([1, 2, 3], name='cummax'), check_dtype=False + result["cummax"], pd.Series([1, 2, 3], name="cummax"), check_dtype=False ) pd.testing.assert_series_equal( - result['cummin'], pd.Series([1, 1, 1], name='cummin'), check_dtype=False + result["cummin"], pd.Series([1, 1, 1], name="cummin"), check_dtype=False ) pd.testing.assert_series_equal( - result['cumsum'], pd.Series([1, 3, 6], name='cumsum'), check_dtype=False + result["cumsum"], pd.Series([1, 3, 6], name="cumsum"), check_dtype=False ) # cumprod uses log trick, check approximate match. 1*1, 1*2, 2*3 = 1, 2, 6. pd.testing.assert_series_equal( - result['cumprod'].round(), pd.Series([1.0, 2.0, 6.0], name='cumprod'), check_dtype=False + result["cumprod"].round(), + pd.Series([1.0, 2.0, 6.0], name="cumprod"), + check_dtype=False, ) pd.testing.assert_series_equal( - result['diff'], pd.Series([float('nan'), 1.0, 1.0], name='diff'), check_dtype=False + result["diff"], + pd.Series([float("nan"), 1.0, 1.0], name="diff"), + check_dtype=False, ) + def test_expression_utility_methods(df): result = df.assign( - isin=col('a').isin([1, 3]), - cast=col('a').astype(pd.ArrowDtype(pa.float64())) + isin=col("a").isin([1, 3]), cast=col("a").astype(pd.ArrowDtype(pa.float64())) ).to_pandas() pd.testing.assert_series_equal( - result['isin'], pd.Series([True, False, True, False, False], name='isin'), check_dtype=False + result["isin"], + pd.Series([True, False, True, False, False], name="isin"), + check_dtype=False, ) pd.testing.assert_series_equal( - result['cast'], pd.Series([1.0, -2.0, 3.0, -4.0, 5.0], name='cast'), check_dtype=False + result["cast"], + pd.Series([1.0, -2.0, 3.0, -4.0, 5.0], name="cast"), + check_dtype=False, ) diff --git a/tests/unit/test_expression_col.py b/tests/unit/test_expression_col.py index 81759fa..171fa16 100644 --- a/tests/unit/test_expression_col.py +++ b/tests/unit/test_expression_col.py @@ -19,26 +19,30 @@ from leanframe.core.session import Session from leanframe.core.expression import Expression, col + def test_col_standalone(): """Verify col() works as a standalone function.""" - expr = col('a') + expr = col("a") assert isinstance(expr, Expression) + def test_col_session_static(): """Verify Session.col() works as a static method.""" - expr = Session.col('a') + expr = Session.col("a") assert isinstance(expr, Expression) + def test_col_session_instance(): """Verify session.col() works as an instance method (compatibility).""" # Use sqlite backend as dummy backend = ibis.sqlite.connect() session = Session(backend) - expr = session.col('a') + expr = session.col("a") assert isinstance(expr, Expression) + def test_col_package_alias(): """Verify leanframe.col is available.""" - expr = leanframe.col('a') + expr = leanframe.col("a") assert isinstance(expr, Expression) diff --git a/tests/unit/test_indexing.py b/tests/unit/test_indexing.py new file mode 100644 index 0000000..0adfab9 --- /dev/null +++ b/tests/unit/test_indexing.py @@ -0,0 +1,383 @@ +""" +Unit tests for indexing functionality in leanframe. + +Tests cover: +- Index creation and properties +- .iloc position-based indexing +- .loc label-based indexing +- .head() and .tail() methods +- Integration with nested data handling +- Error handling +""" + +import pytest +import ibis + +from leanframe.core.frame import DataFrame +from leanframe.core.indexing import Index + + +class TestIndex: + """Tests for the Index class.""" + + def test_index_creation(self): + """Test creating an Index.""" + idx = Index("customer_id", ascending=True) + assert idx.column == "customer_id" + assert idx.columns == ["customer_id"] + assert idx.ascending == [True] + + def test_index_with_name(self): + """Test creating an Index with custom name.""" + idx = Index("timestamp", ascending=False, name="time_idx") + assert idx.column == "timestamp" + assert idx.columns == ["timestamp"] + assert idx.ascending == [False] + assert idx.name == "time_idx" + + def test_index_repr(self): + """Test Index string representation.""" + idx = Index("id", ascending=True) + assert "Index" in repr(idx) + assert "id" in repr(idx) + assert "ascending" in repr(idx) + + def test_multi_column_index(self): + """Test creating a multi-column Index.""" + idx = Index(["priority", "timestamp"], ascending=[False, True]) + assert idx.columns == ["priority", "timestamp"] + assert idx.ascending == [False, True] + assert idx.column == "priority" # First column + assert idx.is_multi_column() is True + + def test_multi_column_index_single_ascending(self): + """Test multi-column index with single ascending value.""" + idx = Index(["col1", "col2", "col3"], ascending=False) + assert idx.columns == ["col1", "col2", "col3"] + assert idx.ascending == [False, False, False] # Applied to all + + def test_multi_column_index_mismatch_error(self): + """Test that mismatched ascending list raises error.""" + with pytest.raises(ValueError, match="Length of ascending"): + Index(["col1", "col2"], ascending=[True, False, True]) + + +class TestSetIndex: + """Tests for DataFrame.set_index() method.""" + + @pytest.fixture + def simple_df(self): + """Create a simple DataFrame for testing.""" + data = { + "id": [1, 2, 3, 4, 5], + "value": [10, 20, 30, 40, 50], + "name": ["a", "b", "c", "d", "e"], + } + ibis_table = ibis.memtable(data) + return DataFrame(ibis_table) + + def test_set_index_basic(self, simple_df): + """Test setting index on a column.""" + df = simple_df.set_index("id") + assert df.index is not None + assert df.index.column == "id" + assert df.index.columns == ["id"] + assert df.index.ascending == [True] + + def test_set_index_descending(self, simple_df): + """Test setting index with descending order.""" + df = simple_df.set_index("value", ascending=False) + assert df.index is not None + assert df.index.column == "value" + assert df.index.columns == ["value"] + assert df.index.ascending == [False] + + def test_set_index_with_name(self, simple_df): + """Test setting index with custom name.""" + df = simple_df.set_index("id", name="custom_idx") + assert df.index.name == "custom_idx" + + def test_set_index_invalid_column(self, simple_df): + """Test setting index on non-existent column raises error.""" + with pytest.raises(KeyError): + simple_df.set_index("nonexistent") + + def test_set_index_returns_new_df(self, simple_df): + """Test that set_index returns a new DataFrame.""" + df_indexed = simple_df.set_index("id") + # Original should not have index + assert simple_df.index is None + # New one should have index + assert df_indexed.index is not None + + def test_set_index_multi_column(self, simple_df): + """Test setting index on multiple columns.""" + df = simple_df.set_index(["id", "value"], ascending=[True, False]) + assert df.index is not None + assert df.index.columns == ["id", "value"] + assert df.index.ascending == [True, False] + assert df.index.is_multi_column() is True + + +class TestILocIndexing: + """Tests for .iloc position-based indexing.""" + + @pytest.fixture + def indexed_df(self): + """Create an indexed DataFrame for testing.""" + data = {"id": [1, 2, 3, 4, 5], "value": [10, 20, 30, 40, 50]} + ibis_table = ibis.memtable(data) + df = DataFrame(ibis_table) + return df.set_index("id", ascending=True) + + def test_iloc_single_row(self, indexed_df): + """Test getting a single row with .iloc.""" + result = indexed_df.iloc[0] + assert isinstance(result, DataFrame) + pandas_result = result.to_pandas() + assert len(pandas_result) == 1 + assert pandas_result["id"].iloc[0] == 1 + + def test_iloc_slice(self, indexed_df): + """Test slicing with .iloc.""" + result = indexed_df.iloc[1:3] + assert isinstance(result, DataFrame) + pandas_result = result.to_pandas() + assert len(pandas_result) == 2 + assert list(pandas_result["id"]) == [2, 3] + + def test_iloc_open_ended_slice(self, indexed_df): + """Test open-ended slice with .iloc.""" + result = indexed_df.iloc[3:] + pandas_result = result.to_pandas() + assert len(pandas_result) == 2 + assert list(pandas_result["id"]) == [4, 5] + + def test_iloc_without_index_raises_error(self): + """Test that .iloc without index raises ValueError.""" + data = {"id": [1, 2, 3]} + ibis_table = ibis.memtable(data) + df = DataFrame(ibis_table) + + with pytest.raises(ValueError, match="Cannot use .iloc without an index"): + df.iloc[0] + + def test_iloc_descending_order(self): + """Test .iloc with descending index order.""" + data = {"id": [1, 2, 3, 4, 5], "value": [10, 20, 30, 40, 50]} + ibis_table = ibis.memtable(data) + df = DataFrame(ibis_table).set_index("id", ascending=False) + + # With descending order, first row should be id=5 + result = df.iloc[0] + pandas_result = result.to_pandas() + assert pandas_result["id"].iloc[0] == 5 + + def test_iloc_multi_column_ordering(self): + """Test .iloc with multi-column index.""" + data = { + "priority": [1, 1, 2, 2, 3], + "timestamp": [5, 3, 4, 2, 1], + "value": [10, 20, 30, 40, 50], + } + ibis_table = ibis.memtable(data) + # Order by priority DESC, then timestamp ASC + df = DataFrame(ibis_table).set_index( + ["priority", "timestamp"], ascending=[False, True] + ) + + # First row should be priority=3, timestamp=1 + result = df.iloc[0] + pandas_result = result.to_pandas() + assert pandas_result["priority"].iloc[0] == 3 + assert pandas_result["timestamp"].iloc[0] == 1 + assert pandas_result["value"].iloc[0] == 50 + + +class TestLocIndexing: + """Tests for .loc label-based indexing.""" + + @pytest.fixture + def indexed_df(self): + """Create an indexed DataFrame for testing.""" + data = {"id": [1, 2, 3, 4, 5], "value": [10, 20, 30, 40, 50]} + ibis_table = ibis.memtable(data) + df = DataFrame(ibis_table) + return df.set_index("id") + + def test_loc_single_value(self, indexed_df): + """Test getting rows by single value with .loc.""" + result = indexed_df.loc[3] + assert isinstance(result, DataFrame) + pandas_result = result.to_pandas() + assert len(pandas_result) == 1 + assert pandas_result["id"].iloc[0] == 3 + assert pandas_result["value"].iloc[0] == 30 + + def test_loc_range(self, indexed_df): + """Test range query with .loc.""" + result = indexed_df.loc[2:4] + pandas_result = result.to_pandas() + assert len(pandas_result) == 3 + assert list(pandas_result["id"]) == [2, 3, 4] + + def test_loc_list_of_values(self, indexed_df): + """Test getting multiple specific values with .loc.""" + result = indexed_df.loc[[1, 3, 5]] + pandas_result = result.to_pandas() + assert len(pandas_result) == 3 + assert set(pandas_result["id"]) == {1, 3, 5} + + def test_loc_without_index_raises_error(self): + """Test that .loc without index raises ValueError.""" + data = {"id": [1, 2, 3]} + ibis_table = ibis.memtable(data) + df = DataFrame(ibis_table) + + with pytest.raises(ValueError, match="Cannot use .loc without an index"): + df.loc[1] + + +class TestHeadTail: + """Tests for .head() and .tail() methods.""" + + @pytest.fixture + def indexed_df(self): + """Create an indexed DataFrame for testing.""" + data = { + "id": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + "value": list(range(10, 110, 10)), + } + ibis_table = ibis.memtable(data) + df = DataFrame(ibis_table) + return df.set_index("id", ascending=True) + + def test_head_default(self, indexed_df): + """Test .head() with default n=5.""" + result = indexed_df.head() + pandas_result = result.to_pandas() + assert len(pandas_result) == 5 + assert list(pandas_result["id"]) == [1, 2, 3, 4, 5] + + def test_head_custom_n(self, indexed_df): + """Test .head() with custom n.""" + result = indexed_df.head(3) + pandas_result = result.to_pandas() + assert len(pandas_result) == 3 + assert list(pandas_result["id"]) == [1, 2, 3] + + def test_tail_default(self, indexed_df): + """Test .tail() with default n=5.""" + result = indexed_df.tail() + pandas_result = result.to_pandas() + assert len(pandas_result) == 5 + assert list(pandas_result["id"]) == [6, 7, 8, 9, 10] + + def test_tail_custom_n(self, indexed_df): + """Test .tail() with custom n.""" + result = indexed_df.tail(3) + pandas_result = result.to_pandas() + assert len(pandas_result) == 3 + assert list(pandas_result["id"]) == [8, 9, 10] + + def test_tail_without_index_raises_error(self): + """Test that .tail() without index raises ValueError.""" + data = {"id": [1, 2, 3]} + ibis_table = ibis.memtable(data) + df = DataFrame(ibis_table) + + with pytest.raises(ValueError, match="Cannot use .tail\\(\\) without an index"): + df.tail() + + def test_head_without_index(self): + """Test that .head() works without index (arbitrary order).""" + data = {"id": [1, 2, 3, 4, 5]} + ibis_table = ibis.memtable(data) + df = DataFrame(ibis_table) + + # Should work but order is arbitrary + result = df.head(3) + pandas_result = result.to_pandas() + assert len(pandas_result) == 3 + + +class TestIndexingWithNestedData: + """Tests for indexing with nested column handling.""" + + @pytest.fixture + def nested_df(self): + """Create a DataFrame with nested data.""" + data = { + "id": [1, 2, 3], + "person": [ + {"name": "Alice", "age": 30}, + {"name": "Bob", "age": 25}, + {"name": "Carol", "age": 35}, + ], + } + ibis_table = ibis.memtable(data) + return DataFrame(ibis_table) + + def test_set_index_on_regular_column(self, nested_df): + """Test setting index on a regular (non-nested) column.""" + df = nested_df.set_index("id") + assert df.index.column == "id" + + def test_extract_then_index(self, nested_df): + """Test extracting nested fields then applying indexing.""" + from leanframe.core.frame import DataFrameHandler + + # Extract nested fields + handler = DataFrameHandler(nested_df) + flat_df = handler.extract_nested_fields(verbose=False) + + # Now index on extracted field + df_indexed = flat_df.set_index("person_age", ascending=False) + + # Get oldest person + result = df_indexed.iloc[0] + pandas_result = result.to_pandas() + assert pandas_result["person_name"].iloc[0] == "Carol" + assert pandas_result["person_age"].iloc[0] == 35 + + +class TestIndexingEdgeCases: + """Tests for edge cases and error conditions.""" + + def test_empty_dataframe(self): + """Test indexing on empty DataFrame.""" + data = {"id": [], "value": []} + ibis_table = ibis.memtable(data) + df = DataFrame(ibis_table).set_index("id") + + # Should not error, just return empty + result = df.head() + pandas_result = result.to_pandas() + assert len(pandas_result) == 0 + + def test_single_row_dataframe(self): + """Test indexing on single row DataFrame.""" + data = {"id": [1], "value": [10]} + ibis_table = ibis.memtable(data) + df = DataFrame(ibis_table).set_index("id") + + result = df.iloc[0] + pandas_result = result.to_pandas() + assert len(pandas_result) == 1 + assert pandas_result["id"].iloc[0] == 1 + + def test_chaining_operations(self): + """Test chaining set_index with other operations.""" + data = {"id": [1, 2, 3, 4, 5], "value": [10, 20, 30, 40, 50]} + ibis_table = ibis.memtable(data) + df = DataFrame(ibis_table) + + # Chain: set_index -> head -> to_pandas + result = df.set_index("id").head(3).to_pandas() + assert len(result) == 3 + assert list(result["id"]) == [1, 2, 3] + + +if __name__ == "__main__": + # Run tests with pytest + pytest.main([__file__, "-v"]) diff --git a/tests/unit/test_series.py b/tests/unit/test_series.py index 4c16ba3..ee2de4d 100644 --- a/tests/unit/test_series.py +++ b/tests/unit/test_series.py @@ -216,18 +216,60 @@ def test_series_arithmetic_scalar(session, op, other, expected_data): @pytest.mark.parametrize( ("op", "other", "expected_data"), [ - pytest.param(lambda s, o: s < o, 3, [True, True, False, False, False], id="lt_scalar"), - pytest.param(lambda s, o: s.lt(o), 3, [True, True, False, False, False], id="lt_method_scalar"), - pytest.param(lambda s, o: s > o, 3, [False, False, False, True, True], id="gt_scalar"), - pytest.param(lambda s, o: s.gt(o), 3, [False, False, False, True, True], id="gt_method_scalar"), - pytest.param(lambda s, o: s <= o, 3, [True, True, True, False, False], id="le_scalar"), - pytest.param(lambda s, o: s.le(o), 3, [True, True, True, False, False], id="le_method_scalar"), - pytest.param(lambda s, o: s >= o, 3, [False, False, True, True, True], id="ge_scalar"), - pytest.param(lambda s, o: s.ge(o), 3, [False, False, True, True, True], id="ge_method_scalar"), - pytest.param(lambda s, o: s != o, 3, [True, True, False, True, True], id="ne_scalar"), - pytest.param(lambda s, o: s.ne(o), 3, [True, True, False, True, True], id="ne_method_scalar"), - pytest.param(lambda s, o: s == o, 3, [False, False, True, False, False], id="eq_scalar"), - pytest.param(lambda s, o: s.eq(o), 3, [False, False, True, False, False], id="eq_method_scalar"), + pytest.param( + lambda s, o: s < o, 3, [True, True, False, False, False], id="lt_scalar" + ), + pytest.param( + lambda s, o: s.lt(o), + 3, + [True, True, False, False, False], + id="lt_method_scalar", + ), + pytest.param( + lambda s, o: s > o, 3, [False, False, False, True, True], id="gt_scalar" + ), + pytest.param( + lambda s, o: s.gt(o), + 3, + [False, False, False, True, True], + id="gt_method_scalar", + ), + pytest.param( + lambda s, o: s <= o, 3, [True, True, True, False, False], id="le_scalar" + ), + pytest.param( + lambda s, o: s.le(o), + 3, + [True, True, True, False, False], + id="le_method_scalar", + ), + pytest.param( + lambda s, o: s >= o, 3, [False, False, True, True, True], id="ge_scalar" + ), + pytest.param( + lambda s, o: s.ge(o), + 3, + [False, False, True, True, True], + id="ge_method_scalar", + ), + pytest.param( + lambda s, o: s != o, 3, [True, True, False, True, True], id="ne_scalar" + ), + pytest.param( + lambda s, o: s.ne(o), + 3, + [True, True, False, True, True], + id="ne_method_scalar", + ), + pytest.param( + lambda s, o: s == o, 3, [False, False, True, False, False], id="eq_scalar" + ), + pytest.param( + lambda s, o: s.eq(o), + 3, + [False, False, True, False, False], + id="eq_method_scalar", + ), ], ) def test_series_comparison_scalar(session, op, other, expected_data): @@ -254,18 +296,54 @@ def test_series_comparison_scalar(session, op, other, expected_data): @pytest.mark.parametrize( ("op", "expected_data"), [ - pytest.param(lambda s1, s2: s1 < s2, [False, False, False, True, True], id="lt_series"), - pytest.param(lambda s1, s2: s1.lt(s2), [False, False, False, True, True], id="lt_method_series"), - pytest.param(lambda s1, s2: s1 > s2, [True, True, False, False, False], id="gt_series"), - pytest.param(lambda s1, s2: s1.gt(s2), [True, True, False, False, False], id="gt_method_series"), - pytest.param(lambda s1, s2: s1 <= s2, [False, False, True, True, True], id="le_series"), - pytest.param(lambda s1, s2: s1.le(s2), [False, False, True, True, True], id="le_method_series"), - pytest.param(lambda s1, s2: s1 >= s2, [True, True, True, False, False], id="ge_series"), - pytest.param(lambda s1, s2: s1.ge(s2), [True, True, True, False, False], id="ge_method_series"), - pytest.param(lambda s1, s2: s1 != s2, [True, True, False, True, True], id="ne_series"), - pytest.param(lambda s1, s2: s1.ne(s2), [True, True, False, True, True], id="ne_method_series"), - pytest.param(lambda s1, s2: s1 == s2, [False, False, True, False, False], id="eq_series"), - pytest.param(lambda s1, s2: s1.eq(s2), [False, False, True, False, False], id="eq_method_series"), + pytest.param( + lambda s1, s2: s1 < s2, [False, False, False, True, True], id="lt_series" + ), + pytest.param( + lambda s1, s2: s1.lt(s2), + [False, False, False, True, True], + id="lt_method_series", + ), + pytest.param( + lambda s1, s2: s1 > s2, [True, True, False, False, False], id="gt_series" + ), + pytest.param( + lambda s1, s2: s1.gt(s2), + [True, True, False, False, False], + id="gt_method_series", + ), + pytest.param( + lambda s1, s2: s1 <= s2, [False, False, True, True, True], id="le_series" + ), + pytest.param( + lambda s1, s2: s1.le(s2), + [False, False, True, True, True], + id="le_method_series", + ), + pytest.param( + lambda s1, s2: s1 >= s2, [True, True, True, False, False], id="ge_series" + ), + pytest.param( + lambda s1, s2: s1.ge(s2), + [True, True, True, False, False], + id="ge_method_series", + ), + pytest.param( + lambda s1, s2: s1 != s2, [True, True, False, True, True], id="ne_series" + ), + pytest.param( + lambda s1, s2: s1.ne(s2), + [True, True, False, True, True], + id="ne_method_series", + ), + pytest.param( + lambda s1, s2: s1 == s2, [False, False, True, False, False], id="eq_series" + ), + pytest.param( + lambda s1, s2: s1.eq(s2), + [False, False, True, False, False], + id="eq_method_series", + ), ], ) def test_series_comparison_series(session, op, expected_data):