Source code for wpostgresql.builders.query_builder

"""Query builder for constructing SQL queries safely."""

import re
from typing import Any, Optional

from wpostgresql.exceptions import SQLInjectionError


[docs] def validate_identifier(identifier: str) -> None: """Validate SQL identifier to prevent SQL injection.""" if not re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*$", identifier): raise SQLInjectionError(f"Invalid identifier: {identifier}")
[docs] class QueryBuilder: """Builder for constructing SQL queries safely."""
[docs] def __init__(self, table_name: str): """Initialize query builder. Args: table_name: Name of the table to query. """ validate_identifier(table_name) self.table_name = table_name self._where_clauses: list[str] = [] self._where_values: list[Any] = [] self._order_by: Optional[str] = None self._order_desc: bool = False self._limit_value: Optional[int] = None self._offset_value: Optional[int] = None
[docs] def where(self, field: str, operator: str, value: Any) -> "QueryBuilder": """Add WHERE condition. Args: field: Field name. operator: Operator (=, <, >, <=, >=, !=, LIKE, IN). value: Value to compare. Returns: Self for chaining. """ validate_identifier(field) valid_operators = {"=", "<", ">", "<=", ">=", "!=", "LIKE", "IN", "IS NULL", "IS NOT NULL"} if operator.upper() not in valid_operators: raise ValueError(f"Invalid operator: {operator}") if operator.upper() == "IN" and not isinstance(value, (list, tuple)): raise ValueError("IN operator requires a list or tuple") if operator.upper() in ("IS NULL", "IS NOT NULL"): self._where_clauses.append(f"{field} {operator.upper()}") else: self._where_clauses.append(f"{field} {operator} %s") if operator.upper() == "IN": self._where_values.extend(value) else: self._where_values.append(value) return self
[docs] def order_by(self, field: str, descending: bool = False) -> "QueryBuilder": """Add ORDER BY clause. Args: field: Field to order by. descending: If True, order descending. Returns: Self for chaining. """ validate_identifier(field) self._order_by = field self._order_desc = descending return self
[docs] def limit(self, limit: int) -> "QueryBuilder": """Add LIMIT clause. Args: limit: Maximum number of rows. Returns: Self for chaining. """ if limit < 0: raise ValueError("Limit must be non-negative") self._limit_value = limit return self
[docs] def offset(self, offset: int) -> "QueryBuilder": """Add OFFSET clause. Args: offset: Number of rows to skip. Returns: Self for chaining. """ if offset < 0: raise ValueError("Offset must be non-negative") self._offset_value = offset return self
[docs] def build_select(self) -> tuple[str, tuple]: """Build SELECT query. Returns: Tuple of (query_string, values). """ query = f"SELECT * FROM {self.table_name}" if self._where_clauses: query += " WHERE " + " AND ".join(self._where_clauses) if self._order_by: direction = "DESC" if self._order_desc else "ASC" query += f" ORDER BY {self._order_by} {direction}" if self._limit_value is not None: query += f" LIMIT {self._limit_value}" if self._offset_value is not None: query += f" OFFSET {self._offset_value}" return query, tuple(self._where_values)
[docs] def build_count(self) -> tuple[str, tuple]: """Build COUNT query. Returns: Tuple of (query_string, values). """ query = f"SELECT COUNT(*) FROM {self.table_name}" if self._where_clauses: query += " WHERE " + " AND ".join(self._where_clauses) return query, tuple(self._where_values)
[docs] def build_delete(self) -> tuple[str, tuple]: """Build DELETE query. Returns: Tuple of (query_string, values). """ if not self._where_clauses: raise ValueError("DELETE requires WHERE clause") query = f"DELETE FROM {self.table_name}" query += " WHERE " + " AND ".join(self._where_clauses) return query, tuple(self._where_values)
[docs] def reset(self) -> "QueryBuilder": """Reset the builder to initial state. Returns: Self for chaining. """ self._where_clauses = [] self._where_values = [] self._order_by = None self._order_desc = False self._limit_value = None self._offset_value = None return self