Coverage for src/dataknobs_data/backends/postgres.py: 12%
536 statements
« prev ^ index » next coverage.py v7.10.3, created at 2025-08-17 19:59 -0500
« prev ^ index » next coverage.py v7.10.3, created at 2025-08-17 19:59 -0500
1"""PostgreSQL backend implementation with proper connection management."""
3import asyncio
4import asyncpg
5import json
6import logging
7import time
8import uuid
9from datetime import datetime
10from typing import Any, AsyncIterator, Iterator, Optional
12from dataknobs_config import ConfigurableBase
13from dataknobs_utils.sql_utils import DotenvPostgresConnector, PostgresDB
15from ..database import AsyncDatabase, SyncDatabase
16from ..query import Operator, Query, SortOrder
17from ..records import Record
18from ..streaming import StreamConfig, StreamResult
19from ..pooling import ConnectionPoolManager
20from ..streaming import async_process_batch_with_fallback, process_batch_with_fallback
21from ..pooling.postgres import (
22 PostgresPoolConfig,
23 create_asyncpg_pool,
24 validate_asyncpg_pool
25)
27logger = logging.getLogger(__name__)
30class SyncPostgresDatabase(SyncDatabase, ConfigurableBase):
31 """Synchronous PostgreSQL database backend with proper connection management."""
33 def __init__(self, config: dict[str, Any] | None = None):
34 """Initialize PostgreSQL database configuration.
36 Args:
37 config: Configuration with the following optional keys:
38 - host: PostgreSQL host (default: from env/localhost)
39 - port: PostgreSQL port (default: 5432)
40 - database: Database name (default: from env/postgres)
41 - user: Username (default: from env/postgres)
42 - password: Password (default: from env)
43 - table: Table name (default: "records")
44 - schema: Schema name (default: "public")
45 """
46 super().__init__(config)
47 self.db = None # Will be initialized in connect()
48 self._connected = False
50 @classmethod
51 def from_config(cls, config: dict) -> "SyncPostgresDatabase":
52 """Create from config dictionary."""
53 return cls(config)
55 def connect(self) -> None:
56 """Connect to the PostgreSQL database."""
57 if self._connected:
58 return # Already connected
60 config = self.config.copy()
62 # Extract table configuration
63 self.table_name = config.pop("table", "records")
64 self.schema_name = config.pop("schema", "public")
66 # Create connection using existing utilities
67 if not any(key in config for key in ["host", "database", "user"]):
68 # Use dotenv connector for environment-based config
69 connector = DotenvPostgresConnector()
70 self.db = PostgresDB(connector)
71 else:
72 # Direct configuration - map 'database' to 'db' for PostgresDB
73 self.db = PostgresDB(
74 host=config.get("host", "localhost"),
75 db=config.get("database", "postgres"), # Note: PostgresDB expects 'db' not 'database'
76 user=config.get("user", "postgres"),
77 pwd=config.get("password"), # Note: PostgresDB expects 'pwd' not 'password'
78 port=config.get("port", 5432),
79 )
81 # Create table if it doesn't exist
82 self._ensure_table()
83 self._connected = True
85 def close(self) -> None:
86 """Close the database connection."""
87 if self.db:
88 # PostgresDB manages its own connections via context managers
89 # but we can mark as disconnected
90 self._connected = False
92 def _initialize(self) -> None:
93 """Initialize method - connection setup moved to connect()."""
94 # Configuration parsing stays here, actual connection in connect()
95 pass
97 def _ensure_table(self) -> None:
98 """Ensure the records table exists."""
99 if not self.db:
100 raise RuntimeError("Database not connected. Call connect() first.")
102 create_table_sql = f"""
103 CREATE TABLE IF NOT EXISTS {self.schema_name}.{self.table_name} (
104 id VARCHAR(255) PRIMARY KEY,
105 data JSONB NOT NULL,
106 metadata JSONB,
107 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
108 updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
109 );
111 CREATE INDEX IF NOT EXISTS idx_{self.table_name}_data
112 ON {self.schema_name}.{self.table_name} USING GIN (data);
114 CREATE INDEX IF NOT EXISTS idx_{self.table_name}_metadata
115 ON {self.schema_name}.{self.table_name} USING GIN (metadata);
116 """
117 self.db.execute(create_table_sql)
119 def _check_connection(self) -> None:
120 """Check if database is connected."""
121 if not self._connected or not self.db:
122 raise RuntimeError("Database not connected. Call connect() first.")
124 def _record_to_row(self, record: Record, id: str | None = None) -> dict[str, Any]:
125 """Convert a Record to a database row."""
126 data = {}
127 for field_name, field_obj in record.fields.items():
128 data[field_name] = field_obj.value
130 return {
131 "id": id or str(uuid.uuid4()),
132 "data": json.dumps(data),
133 "metadata": json.dumps(record.metadata) if record.metadata else None,
134 }
136 def _row_to_record(self, row: dict[str, Any]) -> Record:
137 """Convert a database row to a Record."""
138 data = row.get("data", {})
139 if isinstance(data, str):
140 data = json.loads(data)
142 metadata = row.get("metadata", {})
143 if isinstance(metadata, str) and metadata:
144 metadata = json.loads(metadata)
145 elif not metadata:
146 metadata = {}
148 return Record(data=data, metadata=metadata)
150 def create(self, record: Record) -> str:
151 """Create a new record."""
152 self._check_connection()
153 # Use record's ID if it has one, otherwise generate a new one
154 id = record.id if record.id else str(uuid.uuid4())
155 row = self._record_to_row(record, id)
157 sql = f"""
158 INSERT INTO {self.schema_name}.{self.table_name} (id, data, metadata)
159 VALUES (%(id)s, %(data)s, %(metadata)s)
160 """
161 self.db.execute(sql, row)
162 return id
164 def read(self, id: str) -> Record | None:
165 """Read a record by ID."""
166 self._check_connection()
167 sql = f"""
168 SELECT id, data, metadata
169 FROM {self.schema_name}.{self.table_name}
170 WHERE id = %(id)s
171 """
172 df = self.db.query(sql, {"id": id})
174 if df.empty:
175 return None
177 row = df.iloc[0].to_dict()
178 return self._row_to_record(row)
180 def update(self, id: str, record: Record) -> bool:
181 """Update an existing record."""
182 self._check_connection()
183 row = self._record_to_row(record, id)
185 sql = f"""
186 UPDATE {self.schema_name}.{self.table_name}
187 SET data = %(data)s, metadata = %(metadata)s, updated_at = CURRENT_TIMESTAMP
188 WHERE id = %(id)s
189 """
190 result = self.db.execute(sql, row)
191 # PostgresDB.execute returns number of affected rows
192 return result > 0 if isinstance(result, int) else False
194 def delete(self, id: str) -> bool:
195 """Delete a record by ID."""
196 self._check_connection()
197 sql = f"""
198 DELETE FROM {self.schema_name}.{self.table_name}
199 WHERE id = %(id)s
200 """
201 result = self.db.execute(sql, {"id": id})
202 return result > 0 if isinstance(result, int) else False
204 def exists(self, id: str) -> bool:
205 """Check if a record exists."""
206 self._check_connection()
207 sql = f"""
208 SELECT 1 FROM {self.schema_name}.{self.table_name}
209 WHERE id = %(id)s
210 LIMIT 1
211 """
212 df = self.db.query(sql, {"id": id})
213 return not df.empty
215 def upsert(self, id: str, record: Record) -> str:
216 """Update or insert a record with a specific ID."""
217 self._check_connection()
218 if self.exists(id):
219 self.update(id, record)
220 else:
221 # Insert with specific ID
222 row = self._record_to_row(record, id)
223 sql = f"""
224 INSERT INTO {self.schema_name}.{self.table_name} (id, data, metadata)
225 VALUES (%(id)s, %(data)s, %(metadata)s)
226 """
227 self.db.execute(sql, row)
228 return id
230 def search(self, query: Query) -> list[Record]:
231 """Search for records matching the query."""
232 self._check_connection()
233 # Build SQL query from Query object
234 where_clauses = []
235 params = {}
237 # Build WHERE clauses for filters
238 for i, filter in enumerate(query.filters):
239 field_path = f"data->>'{filter.field}'"
240 param_name = f"param_{i}"
242 if filter.operator == Operator.EQ:
243 # Handle different types appropriately
244 if isinstance(filter.value, bool):
245 where_clauses.append(f"({field_path})::boolean = %({param_name})s")
246 params[param_name] = filter.value
247 elif isinstance(filter.value, (int, float)):
248 where_clauses.append(f"({field_path})::numeric = %({param_name})s")
249 params[param_name] = filter.value
250 else:
251 where_clauses.append(f"{field_path} = %({param_name})s")
252 params[param_name] = str(filter.value)
253 elif filter.operator == Operator.NEQ:
254 if isinstance(filter.value, bool):
255 where_clauses.append(f"({field_path})::boolean != %({param_name})s")
256 params[param_name] = filter.value
257 elif isinstance(filter.value, (int, float)):
258 where_clauses.append(f"({field_path})::numeric != %({param_name})s")
259 params[param_name] = filter.value
260 else:
261 where_clauses.append(f"{field_path} != %({param_name})s")
262 params[param_name] = str(filter.value)
263 elif filter.operator == Operator.GT:
264 where_clauses.append(f"({field_path})::numeric > %({param_name})s")
265 params[param_name] = filter.value
266 elif filter.operator == Operator.LT:
267 where_clauses.append(f"({field_path})::numeric < %({param_name})s")
268 params[param_name] = filter.value
269 elif filter.operator == Operator.GTE:
270 where_clauses.append(f"({field_path})::numeric >= %({param_name})s")
271 params[param_name] = filter.value
272 elif filter.operator == Operator.LTE:
273 where_clauses.append(f"({field_path})::numeric <= %({param_name})s")
274 params[param_name] = filter.value
275 elif filter.operator == Operator.LIKE:
276 where_clauses.append(f"{field_path} LIKE %({param_name})s")
277 params[param_name] = f"%{filter.value}%"
278 elif filter.operator == Operator.IN:
279 # Convert values to strings for comparison with JSONB text fields
280 values = [str(v) for v in filter.value]
281 where_clauses.append(f"{field_path} = ANY(%({param_name})s)")
282 params[param_name] = values
283 elif filter.operator == Operator.NOT_IN:
284 # Convert values to strings for comparison with JSONB text fields
285 values = [str(v) for v in filter.value]
286 where_clauses.append(f"{field_path} != ALL(%({param_name})s)")
287 params[param_name] = values
288 elif filter.operator == Operator.BETWEEN:
289 # Optimize BETWEEN for different data types
290 if isinstance(filter.value, (list, tuple)) and len(filter.value) == 2:
291 lower, upper = filter.value
292 param_lower = f"{param_name}_lower"
293 param_upper = f"{param_name}_upper"
295 # Try to determine the type for proper casting
296 if isinstance(lower, (int, float)) and isinstance(upper, (int, float)):
297 where_clauses.append(
298 f"({field_path})::numeric BETWEEN %({param_lower})s AND %({param_upper})s"
299 )
300 elif isinstance(lower, datetime) or isinstance(upper, datetime):
301 where_clauses.append(
302 f"({field_path})::timestamp BETWEEN %({param_lower})s AND %({param_upper})s"
303 )
304 else:
305 # String or unknown type
306 where_clauses.append(
307 f"{field_path} BETWEEN %({param_lower})s AND %({param_upper})s"
308 )
310 params[param_lower] = lower
311 params[param_upper] = upper
312 elif filter.operator == Operator.NOT_BETWEEN:
313 # Optimize NOT BETWEEN
314 if isinstance(filter.value, (list, tuple)) and len(filter.value) == 2:
315 lower, upper = filter.value
316 param_lower = f"{param_name}_lower"
317 param_upper = f"{param_name}_upper"
319 # Try to determine the type for proper casting
320 if isinstance(lower, (int, float)) and isinstance(upper, (int, float)):
321 where_clauses.append(
322 f"({field_path})::numeric NOT BETWEEN %({param_lower})s AND %({param_upper})s"
323 )
324 elif isinstance(lower, datetime) or isinstance(upper, datetime):
325 where_clauses.append(
326 f"({field_path})::timestamp NOT BETWEEN %({param_lower})s AND %({param_upper})s"
327 )
328 else:
329 # String or unknown type
330 where_clauses.append(
331 f"{field_path} NOT BETWEEN %({param_lower})s AND %({param_upper})s"
332 )
334 params[param_lower] = lower
335 params[param_upper] = upper
337 # Build SQL
338 sql = f"SELECT id, data, metadata FROM {self.schema_name}.{self.table_name}"
339 if where_clauses:
340 sql += " WHERE " + " AND ".join(where_clauses)
342 # Add ORDER BY
343 if query.sort_specs:
344 order_clauses = []
345 for sort_spec in query.sort_specs:
346 # Try to cast to numeric for proper sorting
347 # This will sort numbers correctly while still working for strings
348 field_path = f"data->>'{sort_spec.field}'"
349 direction = "DESC" if sort_spec.order == SortOrder.DESC else "ASC"
350 # Use a CASE statement to handle both numeric and string sorting
351 order_clause = f"""
352 CASE
353 WHEN {field_path} ~ '^[0-9]+(\\.[0-9]+)?$'
354 THEN ({field_path})::numeric
355 ELSE NULL
356 END {direction} NULLS LAST,
357 {field_path} {direction}
358 """
359 order_clauses.append(order_clause)
360 sql += " ORDER BY " + ", ".join(order_clauses)
362 # Add LIMIT and OFFSET
363 if query.limit_value:
364 sql += f" LIMIT {query.limit_value}"
365 if query.offset_value:
366 sql += f" OFFSET {query.offset_value}"
368 # Execute query
369 df = self.db.query(sql, params)
371 # Convert to records
372 records = []
373 for _, row in df.iterrows():
374 record = self._row_to_record(row.to_dict())
376 # Apply field projection if specified
377 if query.fields:
378 record = record.project(query.fields)
380 records.append(record)
382 return records
384 def _count_all(self) -> int:
385 """Count all records in the database."""
386 self._check_connection()
387 sql = f"SELECT COUNT(*) as count FROM {self.schema_name}.{self.table_name}"
388 df = self.db.query(sql)
389 return int(df.iloc[0]["count"]) if not df.empty else 0
391 def clear(self) -> int:
392 """Clear all records from the database."""
393 self._check_connection()
394 # Get count first
395 count = self._count_all()
397 # Delete all records
398 sql = f"TRUNCATE TABLE {self.schema_name}.{self.table_name}"
399 self.db.execute(sql)
401 return count
403 def stream_read(
404 self,
405 query: Optional[Query] = None,
406 config: Optional[StreamConfig] = None
407 ) -> Iterator[Record]:
408 """Stream records from PostgreSQL."""
409 self._check_connection()
410 config = config or StreamConfig()
412 # Build SQL query
413 sql = f"SELECT id, data, metadata FROM {self.schema_name}.{self.table_name}"
414 params = {}
416 if query and query.filters:
417 # Add WHERE clause (simplified for now)
418 where_clauses = []
419 for i, filter in enumerate(query.filters):
420 field_path = f"data->>'{filter.field}'"
421 param_name = f"param_{i}"
423 if filter.operator == Operator.EQ:
424 where_clauses.append(f"{field_path} = %({param_name})s")
425 params[param_name] = str(filter.value)
427 if where_clauses:
428 sql += " WHERE " + " AND ".join(where_clauses)
430 # Use cursor for streaming
431 # Note: PostgresDB may need modification to support cursors
432 # For now, we'll fetch in batches
433 sql += f" LIMIT {config.batch_size} OFFSET %(offset)s"
435 offset = 0
436 while True:
437 params["offset"] = offset
438 df = self.db.query(sql, params)
440 if df.empty:
441 break
443 for _, row in df.iterrows():
444 record = self._row_to_record(row.to_dict())
445 if query and query.fields:
446 record = record.project(query.fields)
447 yield record
449 offset += config.batch_size
451 # If we got less than batch_size, we're done
452 if len(df) < config.batch_size:
453 break
455 def stream_write(
456 self,
457 records: Iterator[Record],
458 config: Optional[StreamConfig] = None
459 ) -> StreamResult:
460 """Stream records into PostgreSQL."""
461 self._check_connection()
462 config = config or StreamConfig()
463 result = StreamResult()
464 start_time = time.time()
465 quitting = False
467 batch = []
468 for record in records:
469 batch.append(record)
471 if len(batch) >= config.batch_size:
472 # Write batch with graceful fallback
473 # Use lambda wrapper for _write_batch
474 continue_processing = process_batch_with_fallback(
475 batch,
476 lambda b: self._write_batch(b) or [r.id for r in b], # _write_batch returns None, we need IDs
477 self.create,
478 result,
479 config
480 )
482 if not continue_processing:
483 quitting = True
484 break
486 batch = []
488 # Write remaining batch
489 if batch and not quitting:
490 process_batch_with_fallback(
491 batch,
492 lambda b: self._write_batch(b) or [r.id for r in b],
493 self.create,
494 result,
495 config
496 )
498 result.duration = time.time() - start_time
499 return result
501 def _write_batch(self, records: list[Record]) -> None:
502 """Write a batch of records to the database."""
503 # Build batch insert SQL
504 values = []
505 params = {}
507 for i, record in enumerate(records):
508 id = str(uuid.uuid4())
509 row = self._record_to_row(record, id)
510 values.append(f"(%(id_{i})s, %(data_{i})s, %(metadata_{i})s)")
511 params[f"id_{i}"] = row["id"]
512 params[f"data_{i}"] = row["data"]
513 params[f"metadata_{i}"] = row["metadata"]
515 sql = f"""
516 INSERT INTO {self.schema_name}.{self.table_name} (id, data, metadata)
517 VALUES {', '.join(values)}
518 """
519 self.db.execute(sql, params)
522# Global pool manager instance for async PostgreSQL connections
523_pool_manager = ConnectionPoolManager[asyncpg.Pool]()
526class AsyncPostgresDatabase(AsyncDatabase, ConfigurableBase):
527 """Native async PostgreSQL database backend with event loop-aware connection pooling."""
529 def __init__(self, config: dict[str, Any] | None = None):
530 """Initialize async PostgreSQL database."""
531 super().__init__(config)
532 config = config or {}
533 self._pool_config = PostgresPoolConfig.from_dict(config)
534 # Add table and schema to pool config from regular config
535 self.table_name = config.get("table", "records")
536 self.schema_name = config.get("schema", "public")
537 self._pool: Optional[asyncpg.Pool] = None
538 self._connected = False
540 @classmethod
541 def from_config(cls, config: dict) -> "AsyncPostgresDatabase":
542 """Create from config dictionary."""
543 return cls(config)
545 async def connect(self) -> None:
546 """Connect to the database."""
547 if self._connected:
548 return
550 # Get or create pool for current event loop
551 self._pool = await _pool_manager.get_pool(
552 self._pool_config,
553 create_asyncpg_pool,
554 validate_asyncpg_pool
555 )
557 # Ensure table exists
558 await self._ensure_table()
559 self._connected = True
561 async def close(self) -> None:
562 """Close the database connection."""
563 if self._connected:
564 # Pool manager handles cleanup
565 self._pool = None
566 self._connected = False
568 def _initialize(self) -> None:
569 """Initialize is handled in connect."""
570 pass
572 async def _ensure_table(self) -> None:
573 """Ensure the records table exists."""
574 if not self._pool:
575 raise RuntimeError("Database not connected. Call connect() first.")
577 create_table_sql = f"""
578 CREATE TABLE IF NOT EXISTS {self.schema_name}.{self.table_name} (
579 id VARCHAR(255) PRIMARY KEY,
580 data JSONB NOT NULL,
581 metadata JSONB,
582 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
583 updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
584 );
586 CREATE INDEX IF NOT EXISTS idx_{self.table_name}_data
587 ON {self.schema_name}.{self.table_name} USING GIN (data);
589 CREATE INDEX IF NOT EXISTS idx_{self.table_name}_metadata
590 ON {self.schema_name}.{self.table_name} USING GIN (metadata);
591 """
593 async with self._pool.acquire() as conn:
594 await conn.execute(create_table_sql)
596 def _check_connection(self) -> None:
597 """Check if database is connected."""
598 if not self._connected or not self._pool:
599 raise RuntimeError("Database not connected. Call connect() first.")
601 def _record_to_row(self, record: Record, id: str | None = None) -> dict[str, Any]:
602 """Convert a Record to a database row."""
603 data = {}
604 for field_name, field_obj in record.fields.items():
605 data[field_name] = field_obj.value
607 return {
608 "id": id or str(uuid.uuid4()),
609 "data": json.dumps(data),
610 "metadata": json.dumps(record.metadata) if record.metadata else None,
611 }
613 def _row_to_record(self, row: asyncpg.Record) -> Record:
614 """Convert a database row to a Record."""
615 data = row.get("data", {})
616 if isinstance(data, str):
617 data = json.loads(data)
619 metadata = row.get("metadata", {})
620 if isinstance(metadata, str) and metadata:
621 metadata = json.loads(metadata)
622 elif not metadata:
623 metadata = {}
625 return Record(data=data, metadata=metadata)
627 async def create(self, record: Record) -> str:
628 """Create a new record."""
629 self._check_connection()
630 # Use record's ID if it has one, otherwise generate a new one
631 id = record.id if record.id else str(uuid.uuid4())
632 row = self._record_to_row(record, id)
634 sql = f"""
635 INSERT INTO {self.schema_name}.{self.table_name} (id, data, metadata)
636 VALUES ($1, $2, $3)
637 """
639 async with self._pool.acquire() as conn:
640 await conn.execute(sql, row["id"], row["data"], row["metadata"])
642 return id
644 async def read(self, id: str) -> Record | None:
645 """Read a record by ID."""
646 self._check_connection()
647 sql = f"""
648 SELECT id, data, metadata
649 FROM {self.schema_name}.{self.table_name}
650 WHERE id = $1
651 """
653 async with self._pool.acquire() as conn:
654 row = await conn.fetchrow(sql, id)
656 if not row:
657 return None
659 return self._row_to_record(row)
661 async def update(self, id: str, record: Record) -> bool:
662 """Update an existing record."""
663 self._check_connection()
664 row = self._record_to_row(record, id)
666 sql = f"""
667 UPDATE {self.schema_name}.{self.table_name}
668 SET data = $2, metadata = $3, updated_at = CURRENT_TIMESTAMP
669 WHERE id = $1
670 """
672 async with self._pool.acquire() as conn:
673 result = await conn.execute(sql, row["id"], row["data"], row["metadata"])
675 # Returns UPDATE n where n is rows affected
676 return result.split()[-1] != "0"
678 async def delete(self, id: str) -> bool:
679 """Delete a record by ID."""
680 self._check_connection()
681 sql = f"""
682 DELETE FROM {self.schema_name}.{self.table_name}
683 WHERE id = $1
684 """
686 async with self._pool.acquire() as conn:
687 result = await conn.execute(sql, id)
689 # Returns DELETE n where n is rows affected
690 return result.split()[-1] != "0"
692 async def exists(self, id: str) -> bool:
693 """Check if a record exists."""
694 self._check_connection()
695 sql = f"""
696 SELECT 1 FROM {self.schema_name}.{self.table_name}
697 WHERE id = $1
698 LIMIT 1
699 """
701 async with self._pool.acquire() as conn:
702 row = await conn.fetchrow(sql, id)
704 return row is not None
706 async def upsert(self, id: str, record: Record) -> str:
707 """Update or insert a record with a specific ID."""
708 self._check_connection()
709 row = self._record_to_row(record, id)
711 sql = f"""
712 INSERT INTO {self.schema_name}.{self.table_name} (id, data, metadata)
713 VALUES ($1, $2, $3)
714 ON CONFLICT (id) DO UPDATE
715 SET data = EXCLUDED.data, metadata = EXCLUDED.metadata, updated_at = CURRENT_TIMESTAMP
716 """
718 async with self._pool.acquire() as conn:
719 await conn.execute(sql, row["id"], row["data"], row["metadata"])
721 return id
723 async def search(self, query: Query) -> list[Record]:
724 """Search for records matching the query."""
725 self._check_connection()
727 # Build SQL query from Query object
728 where_clauses = []
729 params = []
730 param_count = 0
732 # Build WHERE clauses for filters
733 for filter in query.filters:
734 param_count += 1
735 field_path = f"data->>'{filter.field}'"
737 if filter.operator == Operator.EQ:
738 if isinstance(filter.value, bool):
739 where_clauses.append(f"({field_path})::boolean = ${param_count}")
740 params.append(filter.value)
741 elif isinstance(filter.value, (int, float)):
742 where_clauses.append(f"({field_path})::numeric = ${param_count}")
743 params.append(filter.value)
744 else:
745 where_clauses.append(f"{field_path} = ${param_count}")
746 params.append(str(filter.value))
747 elif filter.operator == Operator.NEQ:
748 if isinstance(filter.value, bool):
749 where_clauses.append(f"({field_path})::boolean != ${param_count}")
750 params.append(filter.value)
751 elif isinstance(filter.value, (int, float)):
752 where_clauses.append(f"({field_path})::numeric != ${param_count}")
753 params.append(filter.value)
754 else:
755 where_clauses.append(f"{field_path} != ${param_count}")
756 params.append(str(filter.value))
757 elif filter.operator == Operator.GT:
758 where_clauses.append(f"({field_path})::numeric > ${param_count}")
759 params.append(filter.value)
760 elif filter.operator == Operator.LT:
761 where_clauses.append(f"({field_path})::numeric < ${param_count}")
762 params.append(filter.value)
763 elif filter.operator == Operator.GTE:
764 where_clauses.append(f"({field_path})::numeric >= ${param_count}")
765 params.append(filter.value)
766 elif filter.operator == Operator.LTE:
767 where_clauses.append(f"({field_path})::numeric <= ${param_count}")
768 params.append(filter.value)
769 elif filter.operator == Operator.LIKE:
770 where_clauses.append(f"{field_path} LIKE ${param_count}")
771 params.append(f"%{filter.value}%")
772 elif filter.operator == Operator.IN:
773 values = [str(v) for v in filter.value]
774 where_clauses.append(f"{field_path} = ANY(${param_count})")
775 params.append(values)
776 elif filter.operator == Operator.NOT_IN:
777 values = [str(v) for v in filter.value]
778 where_clauses.append(f"{field_path} != ALL(${param_count})")
779 params.append(values)
780 elif filter.operator == Operator.BETWEEN:
781 # Optimize BETWEEN for different data types
782 if isinstance(filter.value, (list, tuple)) and len(filter.value) == 2:
783 lower, upper = filter.value
785 # Try to determine the type for proper casting
786 if isinstance(lower, (int, float)) and isinstance(upper, (int, float)):
787 where_clauses.append(
788 f"({field_path})::numeric BETWEEN ${param_count} AND ${param_count + 1}"
789 )
790 elif isinstance(lower, datetime) or isinstance(upper, datetime):
791 where_clauses.append(
792 f"({field_path})::timestamp BETWEEN ${param_count} AND ${param_count + 1}"
793 )
794 else:
795 # String or unknown type
796 where_clauses.append(
797 f"{field_path} BETWEEN ${param_count} AND ${param_count + 1}"
798 )
800 params.append(lower)
801 params.append(upper)
802 param_count += 1 # We used two parameters
803 elif filter.operator == Operator.NOT_BETWEEN:
804 # Optimize NOT BETWEEN
805 if isinstance(filter.value, (list, tuple)) and len(filter.value) == 2:
806 lower, upper = filter.value
808 # Try to determine the type for proper casting
809 if isinstance(lower, (int, float)) and isinstance(upper, (int, float)):
810 where_clauses.append(
811 f"({field_path})::numeric NOT BETWEEN ${param_count} AND ${param_count + 1}"
812 )
813 elif isinstance(lower, datetime) or isinstance(upper, datetime):
814 where_clauses.append(
815 f"({field_path})::timestamp NOT BETWEEN ${param_count} AND ${param_count + 1}"
816 )
817 else:
818 # String or unknown type
819 where_clauses.append(
820 f"{field_path} NOT BETWEEN ${param_count} AND ${param_count + 1}"
821 )
823 params.append(lower)
824 params.append(upper)
825 param_count += 1 # We used two parameters
827 # Build SQL
828 sql = f"SELECT id, data, metadata FROM {self.schema_name}.{self.table_name}"
829 if where_clauses:
830 sql += " WHERE " + " AND ".join(where_clauses)
832 # Add ORDER BY
833 if query.sort_specs:
834 order_clauses = []
835 for sort_spec in query.sort_specs:
836 field_path = f"data->>'{sort_spec.field}'"
837 direction = "DESC" if sort_spec.order == SortOrder.DESC else "ASC"
838 # Handle numeric sorting
839 order_clause = f"""
840 CASE
841 WHEN {field_path} ~ '^[0-9]+(\\.[0-9]+)?$'
842 THEN ({field_path})::numeric
843 ELSE NULL
844 END {direction} NULLS LAST,
845 {field_path} {direction}
846 """
847 order_clauses.append(order_clause)
848 sql += " ORDER BY " + ", ".join(order_clauses)
850 # Add LIMIT and OFFSET
851 if query.limit_value:
852 sql += f" LIMIT {query.limit_value}"
853 if query.offset_value:
854 sql += f" OFFSET {query.offset_value}"
856 # Execute query
857 async with self._pool.acquire() as conn:
858 rows = await conn.fetch(sql, *params)
860 # Convert to records
861 records = []
862 for row in rows:
863 record = self._row_to_record(row)
865 # Apply field projection if specified
866 if query.fields:
867 record = record.project(query.fields)
869 records.append(record)
871 return records
873 async def _count_all(self) -> int:
874 """Count all records in the database."""
875 self._check_connection()
876 sql = f"SELECT COUNT(*) as count FROM {self.schema_name}.{self.table_name}"
878 async with self._pool.acquire() as conn:
879 row = await conn.fetchrow(sql)
881 return row["count"] if row else 0
883 async def clear(self) -> int:
884 """Clear all records from the database."""
885 self._check_connection()
886 # Get count first
887 count = await self._count_all()
889 # Delete all records
890 sql = f"TRUNCATE TABLE {self.schema_name}.{self.table_name}"
892 async with self._pool.acquire() as conn:
893 await conn.execute(sql)
895 return count
897 async def stream_read(
898 self,
899 query: Optional[Query] = None,
900 config: Optional[StreamConfig] = None
901 ) -> AsyncIterator[Record]:
902 """Stream records from PostgreSQL using cursor."""
903 self._check_connection()
904 config = config or StreamConfig()
906 # Build SQL query
907 sql = f"SELECT id, data, metadata FROM {self.schema_name}.{self.table_name}"
908 params = []
910 if query and query.filters:
911 where_clauses = []
912 param_count = 0
914 for filter in query.filters:
915 param_count += 1
916 field_path = f"data->>'{filter.field}'"
918 if filter.operator == Operator.EQ:
919 where_clauses.append(f"{field_path} = ${param_count}")
920 params.append(str(filter.value))
922 if where_clauses:
923 sql += " WHERE " + " AND ".join(where_clauses)
925 # Use cursor for efficient streaming
926 async with self._pool.acquire() as conn:
927 async with conn.transaction():
928 cursor = await conn.cursor(sql, *params)
930 batch = []
931 async for row in cursor:
932 record = self._row_to_record(row)
933 if query and query.fields:
934 record = record.project(query.fields)
936 batch.append(record)
938 if len(batch) >= config.batch_size:
939 for rec in batch:
940 yield rec
941 batch = []
943 # Yield remaining records
944 for rec in batch:
945 yield rec
947 async def stream_write(
948 self,
949 records: AsyncIterator[Record],
950 config: Optional[StreamConfig] = None
951 ) -> StreamResult:
952 """Stream records into PostgreSQL using batch inserts."""
953 self._check_connection()
954 config = config or StreamConfig()
955 result = StreamResult()
956 start_time = time.time()
957 quitting = False
959 batch = []
960 async for record in records:
961 batch.append(record)
963 if len(batch) >= config.batch_size:
964 # Write batch with graceful fallback
965 # Use lambda wrapper for _write_batch
966 async def batch_func(b):
967 await self._write_batch(b)
968 return [r.id for r in b]
970 continue_processing = await async_process_batch_with_fallback(
971 batch,
972 batch_func,
973 self.create,
974 result,
975 config
976 )
978 if not continue_processing:
979 quitting = True
980 break
982 batch = []
984 # Write remaining batch
985 if batch and not quitting:
986 async def batch_func(b):
987 await self._write_batch(b)
988 return [r.id for r in b]
990 await async_process_batch_with_fallback(
991 batch,
992 batch_func,
993 self.create,
994 result,
995 config
996 )
998 result.duration = time.time() - start_time
999 return result
1001 async def _write_batch(self, records: list[Record]) -> None:
1002 """Write a batch of records using COPY for performance."""
1003 if not records:
1004 return
1006 # Prepare data for COPY
1007 rows = []
1008 for record in records:
1009 row_data = self._record_to_row(record)
1010 rows.append((
1011 row_data["id"],
1012 row_data["data"],
1013 row_data["metadata"]
1014 ))
1016 # Use COPY for efficient bulk insert
1017 async with self._pool.acquire() as conn:
1018 await conn.copy_records_to_table(
1019 f"{self.schema_name}.{self.table_name}",
1020 records=rows,
1021 columns=["id", "data", "metadata"]
1022 )