From cbcb5ef6fa23a45e94ad47cd2b46b49ff28b0367 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 4 Jun 2025 02:35:59 +0000 Subject: [PATCH] Refactor sqlquerybuilder for security and readability This commit introduces several improvements to the sqlquerybuilder library: 1. **Security:** * Modified query construction (`Q`, `SQLCompiler`) to use parameterized queries (%s placeholders) and separate value lists. This is a critical change to prevent SQL injection vulnerabilities. The `SQLCompiler.sql` property and related methods now return a tuple of (sql_string, parameters_list). * Updated all tests to reflect this new return type and verify correct parameter handling. 2. **Readability & Maintainability:** * Applied PEP 8 styling throughout `sqlquerybuilder/__init__.py` for consistent formatting. * Added comprehensive docstrings (module, class, method) and inline comments to `sqlquerybuilder/__init__.py` and `sqlquerybuilder/tests.py` to clarify functionality. * Refactored the `Q._process` method by breaking down its logic into smaller, private helper methods for different lookup types. This makes the method shorter, easier to understand, and more extensible. These changes significantly enhance the security, robustness, and maintainability of the library. --- sqlquerybuilder/__init__.py | 1136 +++++++++++++++-- .../__pycache__/__init__.cpython-310.pyc | Bin 0 -> 38279 bytes .../__pycache__/tests.cpython-310.pyc | Bin 0 -> 9184 bytes sqlquerybuilder/tests.py | 311 ++++- 4 files changed, 1278 insertions(+), 169 deletions(-) create mode 100644 sqlquerybuilder/__pycache__/__init__.cpython-310.pyc create mode 100644 sqlquerybuilder/__pycache__/tests.cpython-310.pyc diff --git a/sqlquerybuilder/__init__.py b/sqlquerybuilder/__init__.py index 86c382a..92cde49 100644 --- a/sqlquerybuilder/__init__.py +++ b/sqlquerybuilder/__init__.py @@ -1,3 +1,10 @@ +""" +A Python library for dynamically constructing SQL queries in a safe and flexible manner. + +This module provides classes for building SQL queries programmatically, +focusing on preventing SQL injection vulnerabilities by using parameterized queries. +It allows for complex WHERE clauses, joins, ordering, grouping, and more. +""" from __future__ import unicode_literals import sys import copy @@ -9,29 +16,76 @@ VERSION = "0.0.13" + def is_map(obj): + """ + Checks if an object is a map object in Python 3. + + Args: + obj: The object to check. + + Returns: + bool: True if the object is a map, False otherwise. + """ return PYTHON3 and isinstance(obj, map) + def ensureUtf(s): + """ + Ensures a string is UTF-8 encoded. + + Args: + s: The input string or bytes. + + Returns: + str: A UTF-8 encoded string. + """ try: if isinstance(s, str): return s else: return s.encode('utf8', 'ignore') - except: + except BaseException: return str(s) class classproperty(object): + """ + Decorator that converts a method with a single cls argument into a class property. + + Similar to @property, but for class-level attributes. + """ def __init__(self, getter): + """ + Initializes the classproperty decorator. + + Args: + getter: The function to be decorated. + """ self.getter = getter def __get__(self, instance, owner): + """ + Called when the decorated property is accessed. + + Args: + instance: The instance through which the property is accessed (None for class access). + owner: The owner class. + + Returns: + The result of calling the getter function with the owner class. + """ return self.getter(owner) class QMixin(object): + """ + A mixin class providing common boolean operations (&, |, ~) for Q-like objects. + + These operations allow for combining query conditions using AND, OR, and NOT logic. + It also defines constants for these operators and UNION. + """ AND = 'AND' OR = 'OR' NOT = 'NOT' @@ -49,27 +103,86 @@ def __and__(self, other): return self._combine(other, self.AND) def __invert__(self,): + """ + Implements the NOT (~) operator for Q-like objects. + + Returns: + Operator: An Operator instance representing the NOT condition. + """ return Operator(self.NOT, self) class Operator(QMixin): + """ + Represents a logical operator (AND, OR, NOT) combining query conditions. + + Operator objects are typically created by using &, |, or ~ on Q or Operator + instances. They store the operator and the left/right operands. + The `_compile` method generates the SQL string and parameters for the operation. + """ def __init__(self, op=None, left=None, right=None): + """ + Initializes an Operator instance. + + Args: + op (str, optional): The logical operator (e.g., 'AND', 'OR', 'NOT'). + left (QMixin, optional): The left operand (another Q or Operator instance). + right (QMixin, optional): The right operand (another Q or Operator instance). + """ self.op = op self.left = left self.right = right + self.params = [] + # Call _compile to ensure params are populated when an Operator is created + # This is important if an Operator is used directly or its params are + # accessed. + self._compile() - def __repr__(self,): - if self.left and self.right: - return "(%s %s %s)" % (self.left, self.op, self.right) - + def _compile(self): + """ + Compiles the operator into an SQL string and a list of parameters. + Returns (sql_string, params_list) + """ + left_sql, left_params = "", [] + if self.left: + if hasattr(self.left, '_compile'): + left_sql, left_params = self.left._compile() + else: # Should ideally not happen if operands are Q or Operator + left_sql = str(self.left) + + right_sql, right_params = "", [] if self.right: - return "%s" % (self.right) + if hasattr(self.right, '_compile'): + right_sql, right_params = self.right._compile() + else: # Should ideally not happen + right_sql = str(self.right) - if self.left and self.op == "NOT": - return "%s %s" % (self.op, self.left) + self.params = left_params + right_params + + if self.left and self.right: + # e.g., (Q_obj1 AND Q_obj2) + return "(%s %s %s)" % (left_sql, self.op, right_sql), self.params + if self.right: + # This case is unusual for binary operators but could be for unary if 'left' is op + # More relevant for NOT: if op is NOT and left is None, right is the operand + # However, current __invert__ makes Operator(self.NOT, self), so + # left is operand + # Should typically be covered by NOT case + return "%s" % (right_sql), self.params + if self.left and self.op == self.NOT: # NOT operator + # e.g., NOT Q_obj + return "%s %s" % (self.op, left_sql), self.params + + # Fallback or if only left is present (e.g. representing a single Q + # object) + return "%s" % left_sql, self.params - return "%s" % self.left + def __repr__(self,): + # As per instructions, tests will be updated. + # So __repr__ should reflect the final string output. + sql, _ = self._compile() + return sql __str__ = __repr__ @@ -80,11 +193,31 @@ def __bool__(self): class F(object): + """ + Represents an SQL expression or function call. + + F objects are used to refer to model fields or database functions directly + in query construction, without them being treated as literal string values + that need escaping or parameterization in the same way typical values are. + For example, `F('col_name')` would refer to the column `col_name`, or + `F('NOW()')` would insert the `NOW()` function call into the SQL. + + Currently, F objects themselves do not contribute parameters to the query. + """ def __init__(self, value): + """ + Initializes an F object. + + Args: + value (str): The SQL expression or field name. + """ self.value = value def __repr__(self,): + """ + Returns the string representation of the F object, which is its SQL value. + """ return "%s" % self.value __str__ = __repr__ @@ -96,11 +229,25 @@ def __bool__(self): class Q(QMixin): - _mode = "MYSQL" + """ + Represents a query condition or a group of conditions. + + Q objects are used to build complex WHERE clauses by specifying field lookups + and values. They can be combined using logical operators (&, |, ~). + Internally, Q objects compile into an SQL string fragment with placeholders + and a list of parameters to be used by the database driver, preventing + SQL injection. + + Examples: + Q(name="John") # name = 'John' + Q(age__gte=18) # age >= 18 + Q(name="John") & Q(age__gte=18) # (name = 'John' AND age >= 18) + """ + _mode = "MYSQL" # Default SQL mode, can be overridden by Queryset lookup_types = [ - 'icontains', 'istartswith', 'iendswith', - 'contains', 'startswith', 'endswith', + 'icontains', 'istartswith', 'iendswith', + 'contains', 'startswith', 'endswith', 'year', 'month', 'day', 'week_day', 'hour', 'minute', 'second', 'isnull', 'in'] @@ -112,12 +259,42 @@ class Q(QMixin): } def __init__(self, *args, **kwargs): + """ + Initializes a Q object. + + Conditions can be specified as keyword arguments (e.g., `name="John"`, + `age__gte=18`) or as string arguments for simple existence checks + (though less common with parameterized queries). + + Args: + *args: Condition arguments (e.g., "name", "age__gte"). + Typically used if the value is None or for specific lookups + like `isnull` where the value is boolean. + **kwargs: Keyword arguments representing conditions (e.g., `name="value"`). + This is the most common way to define conditions. + """ self.conditions = kwargs for arg in args: - self.conditions[arg] = None + # For args like Q('name__isnull'), the value is often passed in kwargs. + # If an arg is passed without a kwarg value, it implies a specific + # handling, often for `None` or boolean lookups. + if arg not in self.conditions: + self.conditions[arg] = None + self.params = [] # Initialize params list, populated by _compile def __repr__(self,): - return self._compile() + """ + Returns the SQL string representation of the Q object. + + Note: This is primarily for debugging. For actual query execution, + `_compile()` should be used to get both the SQL string and parameters. + """ + # This will eventually be `sql, params = self._compile()`, returning sql for now + # to minimize immediate breakage of unrelated tests. + # However, the task requires _compile to return (sql, params) + # and tests to be updated. So, let's reflect that. + sql, _ = self._compile() + return sql def __bool__(self): return bool(self.conditions) @@ -137,91 +314,315 @@ def datetime_format(self): return "%Y-%m-%d %H:%M:%S" def _get_value(self, value): - if isinstance(value, int) or isinstance(value, float): - return ensureUtf(value) - - if isinstance(value, datetime.datetime): - return "'%s'" % value.strftime(self.datetime_format) - - if isinstance(value, datetime.date): - return "'%s'" % value.strftime(self.date_format) + """ + Processes a value to determine its SQL representation and parameters. + + - For literal values (int, str, datetime, etc.), it returns a placeholder + ('%s') and the value itself in a list (e.g., `("%s", [my_value])`). + - For `SQLQuery` instances (subqueries), it compiles the subquery, + returning its SQL string and its parameters. + - For `F` objects, it returns the F object's string representation (raw SQL) + and an empty parameter list. + - For `Operator` (or `QMixin`) objects, it returns their string representation + and their collected parameters. + - For lists/sets (typically for 'IN' clauses), it recursively calls + `_get_value` for each item, combining the SQL placeholders/fragments + and parameters. + + Args: + value: The value to process. + + Returns: + tuple: A tuple containing: + - str: The SQL string fragment or placeholder(s). + - list: A list of parameters associated with the value. + """ + if isinstance( + value, + SQLQuery): # Handle SQLQuery specifically for subqueries + # Explicitly clone the subquery before calling .sql on it + # to ensure no state from a previous compilation (if any) interferes, + # or no state from this compilation pollutes the original subquery object + # if it's used elsewhere. + cloned_value = value._clone() + # .sql property returns (sql_str, params_list) + sub_sql_str, sub_params_list = cloned_value.sql + # Do not add extra parentheses here; the IN clause processing will + # add them. + return sub_sql_str, sub_params_list + + if isinstance( + value, F) or isinstance( + value, QMixin): # Handles F and Operator + # F objects don't have params. + # Operator objects have their params on self.params attribute due + # to their own _compile. + return ensureUtf(value), getattr(value, 'params', []) if isinstance(value, list) or isinstance(value, set) or is_map(value): - return ", ".join([self._get_value(item) for item in value]) - - if isinstance(value, F) or isinstance(value, QMixin) or isinstance(value, SQLQuery): - return ensureUtf(value) - - return "'%s'" % value + placeholders = [] + params = [] + for item in value: + # Recursively call _get_value for each item in the list + # This handles cases like a list of F objects or a list of + # actual values + item_placeholder, item_params = self._get_value(item) + placeholders.append(item_placeholder) + # item_params would be [item_value] or [] + params.extend(item_params) + return ", ".join(placeholders), params + + # For all other types, return '%s' and the value in a list + return "%s", [value] def _process(self, compose_column, value): + """ + Processes a single column-value condition and generates its SQL and parameters. + + It parses the `compose_column` string (e.g., "name__startswith") to + extract the column name and lookup type (e.g., "startswith", "gte"). + Based on the lookup type, it formats the SQL condition string with + placeholders and collects the corresponding parameters using `_get_value`. + + Args: + compose_column (str): The condition string, potentially including + lookup types (e.g., "field__type"). + value: The value for the condition. + + Returns: + tuple: A tuple containing: + - str: The SQL fragment for this condition (e.g., "name LIKE %s"). + - list: A list of parameters for this condition (e.g., ["John%"]). + """ arr = compose_column.split("__") column = arr.pop(0) - if column == '': + if column == '': # Handle cases like __exact=value column += "__" + arr.pop(0) + params = [] try: lookup = arr.pop(0) - except: + except IndexError: lookup = None - if lookup in self.lookup_types: - if lookup == "icontains": - return "{0} LIKE '%{1}%'".format(column, value) - - if lookup == "iendswith": - return "{0} LIKE '%{1}'".format(column, value) - - if lookup == "istartswith": - return "{0} LIKE '{1}%'".format(column, value) - - if lookup == "contains": - return "{0} LIKE BINARY '%{1}%'".format(column, value) - - if lookup == "endswith": - return "{0} LIKE BINARY '%{1}'".format(column, value) - - if lookup == "startswith": - return "{0} LIKE BINARY '{1}%'".format(column, value) - - if lookup == "in": - return "{0} in ({1})".format(column, self._get_value(value)) - - if lookup == 'isnull': - op = "" - if not value: - op = "NOT " - return "{0} is {1}NULL".format(column, op) - - if lookup in ['year', 'month', 'day', 'hour', 'minute', 'second']: - if arr: - column = "DATEPART({0}, {1})__{2}".format(lookup, column, arr.pop(0)) - return self._process(column, value) - else: - return "DATEPART({0}, {1})={2}".format(lookup, column, value) - - if lookup in self.op_map.keys(): - return "{0}{1}{2}".format(column, self.op_map[lookup], self._get_value(value)) - + # Special handling for F objects, QMixin, SQLQuery - they don't use + # placeholders themselves + # This initial check for F, QMixin, SQLQuery as direct values is primarily + # to ensure that _get_value is called on them if they are part of, for example, + # a list in an IN clause. The actual parameter collection for these + # types (especially SQLQuery and Operator) happens within _get_value. + # For simple cases like Q(name=F('other_column')), the F object is + # handled by _get_value called during _handle_default_lookup or _handle_comparison_lookup. + # This block doesn't directly add params; _get_value does. + if isinstance(value, (F, QMixin, SQLQuery)): + pass # _get_value will be called by the specific handlers. + + # --- String Lookups --- + if lookup in ('icontains', 'istartswith', 'iendswith', + 'contains', 'startswith', 'endswith'): + return self._handle_string_lookup(column, lookup, value) + + # --- IN Lookup --- + if lookup == "in": + return self._handle_in_lookup(column, value) + + # --- ISNULL Lookup --- + if lookup == 'isnull': + return self._handle_isnull_lookup(column, value) + + # --- Date Lookups --- + if lookup in ('year', 'month', 'day', 'hour', 'minute', 'second'): + # arr contains remaining parts of lookup, e.g., ['gte'] for 'year__gte' + # or is empty for just 'year' + return self._handle_date_lookup(column, [lookup] + arr, value) + + # --- Comparison Lookups (gte, lte, gt, lt) --- + if lookup in self.op_map: + return self._handle_comparison_lookup(column, lookup, value) + + # --- Default Lookup (equality or existence) --- + # This is reached if lookup is None (e.g., Q(name="value")) + # or if lookup was not any of the above special types (e.g. Q(name__exact="value") - 'exact' is not special) + # If lookup is not None here, it implies an exact match or a lookup not explicitly handled above. + # The original code effectively treated unhandled lookups as equality if value was not None. + if lookup is None or lookup == 'exact' or lookup == 'iexact': # Treat 'exact' and 'iexact' as default equality too + return self._handle_default_lookup(column, value) + + # If lookup is something else not covered (e.g. a typo or unsupported lookup), + # it might fall through to just returning the column name if value is None. + # Or, if strict, raise an error. Current behavior is to try default lookup. + # If value is None at this point (e.g. Q("name") ), it's an existence check. + if value is None: + return column, [] # Existence check for the column itself + + # Fallback for any other unhandled lookup type with a value: treat as equality + return self._handle_default_lookup(column, value) + + + # --- Helper methods for _process --- + + def _handle_string_lookup(self, column, lookup, value): + """ + Handles string-based lookups (LIKE, BINARY LIKE). + e.g., 'contains', 'startswith', 'icontains'. + """ + params = [] + sql_format = "" + param_value_format = "{0}" + binary = "" + + if lookup.startswith("i"): # Case-insensitive + lookup = lookup[1:] + else: # Case-sensitive + binary = " BINARY" # For MySQL style LIKE BINARY + + if lookup == "contains": + sql_format = "{0} LIKE{1} %s" + param_value_format = "%{0}%" + elif lookup == "startswith": + sql_format = "{0} LIKE{1} %s" + param_value_format = "{0}%" + elif lookup == "endswith": + sql_format = "{0} LIKE{1} %s" + param_value_format = "%{0}" + + if not sql_format: + # Should not happen if called correctly + raise ValueError(f"Unsupported string lookup: {lookup}") + + placeholder, value_params = self._get_value(param_value_format.format(value)) + params.extend(value_params) + return sql_format.format(column, binary), params + + def _handle_date_lookup(self, column, lookup_parts, value): + """ + Handles date-based lookups (DATEPART). + e.g., 'year', 'month__gte'. + """ + params = [] + # lookup_parts is like ['year'], or ['year', 'gte'] + date_part = lookup_parts.pop(0) + + # Construct the DATEPART expression for the column + # DATEPART syntax might vary slightly based on self._mode, but using generic for now + # In the original code, it was DATEPART(part, column) for SQL Server. + # For MySQL, it would be EXTRACT(part FROM column) or YEAR(column), MONTH(column) etc. + # The original code used DATEPART for all, which is not universally correct but was the existing behavior. + # We will stick to DATEPART as per original logic to avoid changing SQL dialect specifics here. + datepart_column_expr = "DATEPART({0}, {1})".format(date_part, column) + + if not lookup_parts: # Direct equality, e.g., fecha__year=2020 + placeholder, value_params = self._get_value(value) + params.extend(value_params) + return "{0} = {1}".format(datepart_column_expr, placeholder), params + else: # Chained lookup, e.g., fecha__year__gte=2020 + # The remaining part is the operator, e.g., 'gte' + next_lookup = lookup_parts.pop(0) + # Re-process with the DATEPART expression as the new "column" + # This is a recursive call to the main _process logic with a modified column + # and the next lookup part. + # Example: _process("DATEPART(year, fecha)__gte", value) + return self._process("{0}__{1}".format(datepart_column_expr, next_lookup), value) + + def _handle_isnull_lookup(self, column, value): + """ + Handles 'isnull' lookups. + """ + op = "IS" + if not value: # value is True for "IS NULL", False for "IS NOT NULL" + op += " NOT" + return "{0} {1} NULL".format(column, op), [] # No params for IS NULL + + def _handle_in_lookup(self, column, value): + """ + Handles 'in' lookups. + """ + params = [] + placeholders_str, value_params = self._get_value(value) + params.extend(value_params) + return "{0} IN ({1})".format(column, placeholders_str), params + + def _handle_comparison_lookup(self, column, lookup, value): + """ + Handles comparison lookups (e.g., 'gte', 'lt'). + Uses self.op_map. + """ + params = [] + placeholder, value_params = self._get_value(value) + params.extend(value_params) + return "{0} {1} {2}".format(column, self.op_map[lookup], placeholder), params + + def _handle_default_lookup(self, column, value): + """ + Handles default equality lookup or existence check. + """ if value is not None: - return "{0}{1}{2}".format(column, "=", self._get_value(value)) + params = [] + placeholder, value_params = self._get_value(value) + params.extend(value_params) + return "{0} = {1}".format(column, placeholder), params + return column, [] # For existence checks like Q('some_column') - return column + # --- End of helper methods --- def _compile(self,): - filters = [] + """ + Compiles all conditions within this Q object into a single SQL string + and an aggregated list of parameters. + + Conditions are joined by "AND". The resulting SQL string is typically + enclosed in parentheses. This method is called recursively by `Operator` + objects to build complex query structures. + + The parameters collected are stored in `self.params` and also returned. + + Returns: + tuple: A tuple containing: + - str: The compiled SQL string fragment for this Q object's conditions. + - list: An aggregated list of parameters for all conditions. + """ + filter_parts = [] + current_params = [] for k, v in self.conditions.items(): - filters.append(self._process(k, v)) + sql_part, item_params = self._process(k, v) + filter_parts.append(sql_part) + current_params.extend(item_params) - if filters: - return "(%s)" % " AND ".join(filters) + self.params = current_params # Store params on the Q object itself - return "" + if not filter_parts: + return "", [] + + return "(%s)" % " AND ".join(filter_parts), current_params class SQLQuery(object): + """ + Represents a base SQL query structure that can be built upon. + + This class provides methods to define various parts of an SQL query such as + selecting columns (`values`), filtering (`filter`, `exclude`), ordering + (`order_by`), grouping (`group_by`), joining tables (`join`), and applying + limits/offsets. + + It is not typically used directly by end-users but is inherited by `Queryset`. + The methods in this class usually return a deep copy (clone) of the + instance to allow for chaining and immutable query construction. + """ def __init__(self, table=None, sql_mode="MYSQL", sql=None, **kwargs): + """ + Initializes an SQLQuery instance. + + Args: + table (str, optional): The primary table name for the query. + sql_mode (str, optional): The SQL dialect mode (e.g., "MYSQL", "SQL_SERVER"). + Defaults to "MYSQL". This affects date formatting + and limit/offset clause generation. + sql (str, optional): An initial raw SQL string to base this query on. + Useful for creating a Queryset from an existing complex query. + **kwargs: Additional keyword arguments (currently unused but captured). + """ self.kwargs = kwargs self._table = table self.sql_mode = sql_mode @@ -229,19 +630,44 @@ def __init__(self, table=None, sql_mode="MYSQL", sql=None, **kwargs): self._order_by = [] self._group_by = [] self._joins = [] - self._filters = Q() - self._excludes = Q() + self._filters = Q() # Q object now has its own .params + self._filters._mode = self.sql_mode + self._excludes = Q() # Q object now has its own .params + self._excludes._mode = self.sql_mode self._extra = {} self._limits = None self._sql = sql self._nolock = False + self.params = [] # For parameters collected by SQLCompiler def has_filters(self,): + """ + Checks if the query has any clauses that would modify a simple "SELECT * FROM table". + + This includes filters, excludes, ordering, grouping, joins, custom column selections, + limits, or extra clauses. + + Returns: + bool: True if any such clauses are present, False otherwise. + """ return self._order_by or self._group_by or self._joins\ or self._filters or self._excludes or self._extra \ or self._limits or self._values != ['*'] def _q(self, *args, **kwargs): + """ + Helper method to create Q objects with the correct SQL mode. + + This ensures that Q objects created internally by the Queryset + use the same SQL mode as the Queryset itself (important for date formatting, etc.). + + Args: + *args: Positional arguments for the Q object. + **kwargs: Keyword arguments for the Q object. + + Returns: + Q: A Q object initialized with the Queryset's SQL mode. + """ conds = Q() conds._mode = self.sql_mode for arg in args: @@ -254,46 +680,150 @@ def _q(self, *args, **kwargs): return conds & _conds def _clone(self,): + """ + Creates a deep copy of the current SQLQuery instance. + + This is crucial for maintaining immutability when chaining query-building methods. + + Returns: + SQLQuery: A deep copy of the instance. + """ return copy.deepcopy(self) def values(self, *args): + """ + Specifies the columns to be selected in the query. + + If not called, defaults to selecting all columns ('*'). + + Args: + *args: A list of column names or expressions to select. + + Returns: + SQLQuery: A new SQLQuery instance with the specified columns. + """ clone = self._clone() clone._values = list(args) return clone def with_nolock(self, enabled=True): + """ + Enables or disables the 'WITH (NOLOCK)' hint (primarily for SQL Server). + + Args: + enabled (bool, optional): True to enable NOLOCK, False to disable. + Defaults to True. + + Returns: + SQLQuery: A new SQLQuery instance with the NOLOCK hint setting. + """ clone = self._clone() clone._nolock = enabled return clone def filter(self, *args, **kwargs): + """ + Adds filtering conditions (WHERE clause) to the query. + + Conditions are typically Q objects or keyword arguments that can be + converted to Q objects. Multiple calls to filter() will AND the + conditions together. + + Args: + *args: Q objects or other filter arguments. + **kwargs: Field lookups (e.g., `name="John"`, `age__gte=18`). + + Returns: + SQLQuery: A new SQLQuery instance with the added filter conditions. + """ clone = self._clone() clone._filters &= self._q(*args, **kwargs) return clone def exclude(self, *args, **kwargs): + """ + Adds exclusion conditions (NOT WHERE clause) to the query. + + These conditions specify records to exclude from the result set. + Multiple calls to exclude() will AND the exclusion conditions together. + + Args: + *args: Q objects or other filter arguments for exclusion. + **kwargs: Field lookups for exclusion. + + Returns: + SQLQuery: A new SQLQuery instance with the added exclusion conditions. + """ clone = self._clone() clone._excludes &= self._q(*args, **kwargs) return clone def order_by(self, *args): + """ + Specifies the ordering of the result set (ORDER BY clause). + + Args: + *args: Column names to order by. Prefix with '-' for descending order + (e.g., `"-age"` for descending age). + + Returns: + SQLQuery: A new SQLQuery instance with the specified ordering. + """ clone = self._clone() clone._order_by = args return clone def group_by(self, *args): + """ + Specifies grouping for the result set (GROUP BY clause). + + Args: + *args: Column names to group by. + + Returns: + SQLQuery: A new SQLQuery instance with the specified grouping. + """ clone = self._clone() clone._group_by = args return clone def join(self, table, on="", how="inner join"): + """ + Adds a JOIN clause to the query. + + Args: + table (str): The name of the table to join. + on (str, optional): The JOIN condition (e.g., "other_table.id = main_table.fk_id"). + Can use `{table}` as a placeholder for the primary table name. + how (str, optional): The type of join (e.g., "INNER JOIN", "LEFT JOIN"). + Defaults to "inner join". + + Returns: + SQLQuery: A new SQLQuery instance with the added JOIN clause. + """ clone = self._clone() if on: on = "ON " + on.format(table=self._table) - clone._joins.append("{how} {table} {on}".format(how=how, table=table, on=on)) + clone._joins.append( + "{how} {table} {on}".format( + how=how, table=table, on=on)) return clone def extra(self, extra=None, **kwargs): + """ + Adds extra, raw SQL snippets to various parts of the query. + + This method allows for advanced customization but should be used with caution + as it can bypass parameterization for the provided raw SQL. + + Args: + extra (dict, optional): A dictionary where keys are query parts (e.g., "select", "where") + and values are SQL strings or lists of SQL strings. + **kwargs: Can also be used to pass query parts, e.g., `select="COUNT(*) as total"`. + + Returns: + SQLQuery: A new SQLQuery instance with the extra SQL snippets. + """ clone = self._clone() if extra: clone._extra.update(extra) @@ -302,37 +832,150 @@ def extra(self, extra=None, **kwargs): return clone def __getitem__(self, slice): + """ + Implements slicing for limit/offset functionality (LIMIT/OFFSET clause). + + Args: + slice (slice): A slice object (e.g., `[:10]`, `[5:15]`). + + Returns: + SQLQuery: A new SQLQuery instance with the specified limit/offset. + """ clone = self._clone() clone._limits = slice return clone class SQLCompiler(object): + """ + Compiles the SQLQuery object's state into an executable SQL string and parameters. + + This class takes the structured query information from an SQLQuery instance + (which it's typically mixed into via Queryset) and translates it into the + final SQL query string with placeholders, along with a list of parameters + for safe execution. + """ def get_columns(self,): + """ + Constructs the column selection part of the SQL query. + + Combines explicitly selected columns (from `_values`) with any + columns added via the `extra(select=...)` mechanism. + + Returns: + str: The SQL string for column selection (e.g., "col1, col2, COUNT(*)"). + """ extra_columns = self.get_extra_columns() columns = ", ".join(self._values) return ", ".join([item for item in [columns, extra_columns] if item]) def get_extra_columns(self,): + """ + Retrieves extra columns specified via `extra(select=...)`. + + Returns: + str: SQL string for extra selected columns, or empty string if none. + """ return self._extra.get("select", "") def get_extra_where(self,): + """ + Retrieves extra WHERE conditions specified via `extra(where=...)`. + + These are typically raw SQL strings. + + Returns: + str: An "AND"-joined SQL string of extra WHERE conditions, or None. + """ where = self._extra.get("where", []) if where: return " AND ".join(where) def get_table(self,): + """ + Gets the primary table name for the query. + + Returns: + str: The table name. + """ return self._table def get_where(self): - filters = ensureUtf(self._filters & ~self._excludes) - extra_where = self.get_extra_where() - - if filters or extra_where: - return "WHERE " + " AND ".join([item for item in [filters, extra_where] if item]) + """ + Generates the WHERE clause SQL string and collects its parameters. + + It compiles the `_filters` and `_excludes` Q objects (handling the + `NOT` for excludes) and combines their SQL and parameters. It also + appends any raw SQL from `extra(where=...)`. + + Returns: + tuple or None: A tuple `(sql_where_clause, params_list)` if there are + conditions, otherwise None. + Example: `("WHERE (name = %s) AND NOT (age < %s)", ["John", 10])` + """ + # _filters and _excludes are Q objects (or Operators). + # Their combination using & and ~ will result in an Operator instance. + # This Operator instance's _compile method will return (sql, params). + + # Compile filters + filters_sql, filters_params = "", [] + if self._filters: # Check if _filters (Q object) has any conditions + filters_sql, filters_params = self._filters._compile() + + # Compile excludes + excludes_sql, excludes_params = "", [] + if self._excludes: # Check if _excludes (Q object) has any conditions + # Note: The ~ operator is handled by Operator._compile if self._excludes is part of a larger expression. + # Here, we are specifically compiling the _excludes Q object itself. + # The negation (NOT) is applied when combining with filters. + excludes_sql, excludes_params = self._excludes._compile() + + # Combine filter and exclude SQL and params + # The actual "NOT" logic for excludes is handled by how these are combined. + # self._filters & ~self._excludes creates an Operator. + # We need to get params from that combined operation. + + final_combined_q = self._filters + if excludes_sql: # Only add NOT if there's something to exclude + final_combined_q = self._filters & ~self._excludes + + # Now compile the potentially combined Q/Operator object + combined_sql, combined_params = final_combined_q._compile() + + extra_where = self.get_extra_where() # Assumed to be raw SQL string, no params + + if not combined_sql and not extra_where: + return None # No WHERE clause needed + + where_parts = [] + # Start with params from Q/Operator + current_params = list(combined_params) + + if combined_sql: + where_parts.append(ensureUtf(combined_sql)) + + if extra_where: + if isinstance(extra_where, list): + where_parts.extend(extra_where) + else: + where_parts.append(extra_where) + + if not where_parts: + return None # Should not happen if combined_sql or extra_where was present + + return "WHERE " + " AND ".join(where_parts), current_params def get_order_by(self,): + """ + Constructs the ORDER BY clause of the SQL query. + + Handles ascending and descending order (prefix column with '-'). + + Returns: + str or None: The SQL ORDER BY clause (e.g., "ORDER BY name, age DESC"), + or None if no ordering is specified. + """ conds = [] for cond in self._order_by: order = "" @@ -344,7 +987,7 @@ def get_order_by(self,): if cond[0] == "-": order = " DESC" column = cond[1:] - except: + except BaseException: pass conds.append("{0}{1}".format(column, order)) @@ -353,91 +996,350 @@ def get_order_by(self,): return "ORDER BY " + ", ".join(conds) def get_group_by(self,): + """ + Constructs the GROUP BY clause of the SQL query. + + Returns: + str or None: The SQL GROUP BY clause (e.g., "GROUP BY department"), + or None if no grouping is specified. + """ if self._group_by: return "GROUP BY " + ", ".join(self._group_by) def get_joins(self,): + """ + Constructs the JOIN clauses of the SQL query. + + Returns: + str or None: The SQL JOIN clauses (e.g., "INNER JOIN table2 ON ..."), + or None if no joins are specified. + """ if self._joins: return " ".join(self._joins) def get_nolock(self,): + """ + Returns the 'WITH (NOLOCK)' hint string if enabled (for SQL Server). + + Returns: + str: " WITH (NOLOCK)" if enabled, otherwise an empty string. + """ if self._nolock: return " WITH (NOLOCK)" return "" def get_limits(self,): + """ + Constructs the LIMIT/OFFSET clause (for MySQL/PostgreSQL style databases). + + Not used for SQL Server TOP clause. + + Returns: + str or None: The SQL LIMIT/OFFSET clause (e.g., "LIMIT 10 OFFSET 5"), + or None if no limits/offset are specified or if in SQL Server mode. + """ if self._limits and self.sql_mode != "SQL_SERVER": offset = self._limits.start limit = self._limits.stop if offset: limit = limit - offset - str = "LIMIT {0}".format(limit) + s = "LIMIT {0}".format(limit) # Renamed str to s if offset: - str += " OFFSET {0}".format(offset) - return str + s += " OFFSET {0}".format(offset) + return s + return None # Ensure None is returned if not applicable def get_top(self,): + """ + Constructs the TOP clause (for SQL Server). + + Used when limits are specified but no offset (start is None). + + Returns: + str or None: The SQL TOP clause (e.g., "TOP 10"), or None if not applicable. + """ if self._limits and self.sql_mode == "SQL_SERVER" and not self._limits.start: return "TOP {0}".format(self._limits.stop) + return None # Ensure None is returned if not applicable def get_sql_structure(self): - if self._sql: - if not self.has_filters(): - return [self._sql] + """ + Assembles the main structure of the SQL query as a list of its component strings. + + This method calls other `get_*` methods (like `get_columns`, `get_where`, etc.) + to build the different parts of the query. It also handles SQL Server's + ROW_NUMBER() OVER() pagination logic if applicable. + + The parameters from the WHERE clause are also retrieved here and passed along. + + Returns: + tuple: A tuple containing: + - list: A list of SQL string components (e.g., ["SELECT", "col1", "FROM", "table", ...]). + - list: A list of parameters collected from the WHERE clause. + """ + if self._sql: # If raw SQL is provided initially + if not self.has_filters(): # And no other modifications, return it as is + # If self._sql was from a previous Queryset, it might have params. + # This case needs careful handling if we want to chain parameterized raw SQL. + # For now, assume self._sql does not come with its own params + # that need merging. + return [self._sql], [] + # Wrap raw SQL if filters are added table = "(%s) as union1" % self._sql else: table = self.get_table() - sql = ["SELECT", self.get_top(), self.get_columns(), - "FROM", table, - self.get_nolock(), - self.get_joins(), self.get_where(), - self.get_group_by(), self.get_order_by(), - self.get_limits()] - + # Prepare components. get_where() now returns (sql_str, params_list) or + # None + where_clause_tuple = self.get_where() + where_sql = None + where_params = [] + if where_clause_tuple: + where_sql, where_params = where_clause_tuple + + # Base SQL structure + sql_parts = ["SELECT", self.get_top(), self.get_columns(), + "FROM", table, + self.get_nolock(), + self.get_joins(), where_sql, # where_sql might be None + self.get_group_by(), self.get_order_by(), + self.get_limits()] + + # SQL Server specific pagination if self.sql_mode == "SQL_SERVER" and self._limits and \ self._limits.start is not None and self._limits.stop is not None: - conds = [] - if self._limits.start is not None: - conds.append("row_number > %s" % self._limits.start) + # These limit values are directly embedded. If parameterization is needed here, + # this part would also need to return placeholders and params. + # For now, assuming they are not parameterized as per typical SQL + # Server TOP/ROW_NUMBER usage. + conds_list = [] + if self._limits.start is not None: + # This is SQL string construction, not parameterization. + # If these numbers were from user input, they should be parameterized. + # However, slice indices are usually developer-defined. + conds_list.append("row_number > %s" % self._limits.start) if self._limits.stop is not None: - conds.append("row_number <= %s" % self._limits.stop) + conds_list.append("row_number <= %s" % self._limits.stop) + conds_str = " AND ".join(conds_list) + + paginate_order_by = self.get_order_by() # ORDER BY for ROW_NUMBER() + if not paginate_order_by: # ROW_NUMBER() requires an ORDER BY clause + # Default order by if none provided, to make ROW_NUMBER syntactically correct + # This might need to pick a default column, e.g., PK if known, or first selected column + # For now, this is a simplification. A robust solution would ensure a valid column. + # Or raise an error if order_by is missing for pagination. + # This aspect is outside the primary goal of SQL injection for + # WHERE clause. + if self._values and self._values != ['*']: + paginate_order_by = "ORDER BY " + \ + self._values[0].split(' ')[0] # Order by first column + else: # Cannot determine a column, this will likely be a SQL syntax error if not handled + paginate_order_by = "ORDER BY (SELECT NULL)" + + paginate_clause = "ROW_NUMBER() OVER (%s) as row_number" % paginate_order_by + + # The WHERE clause for the outer query (tbl_paginated) is conds_str + # The inner query might have its own WHERE clause (where_sql from + # get_where()) + + sql_parts = ["SELECT * FROM (", + "SELECT", ",".join( + [item for item in [paginate_clause, self.get_columns()] if item]), + "FROM", table, + self.get_joins(), + where_sql, # Inner WHERE clause + self.get_group_by(), # Inner GROUP BY + # self.get_order_by(), # Order by is in ROW_NUMBER() + # self.get_limits(), # Limits are applied by + # ROW_NUMBER and outer WHERE + ") as tbl_paginated WHERE ", conds_str] + # Params from the inner where_sql are already in where_params + # conds_str currently doesn't add params. + + return sql_parts, where_params # Return parts and params from where clause - conds = " AND ".join(conds) - paginate = "ROW_NUMBER() OVER (%s) as row_number" % self.get_order_by() + def _compile(self): + """ + Compiles the entire SQL query into its final SQL string and parameter list. - return ["SELECT * FROM (", "SELECT", ",".join([paginate, self.get_columns()]), - "FROM", table, - self.get_joins(), - self.get_where(), - self.get_group_by(), - self.get_limits(), ") as tbl_paginated WHERE ", conds] + This method orchestrates the assembly of all SQL parts by calling + `get_sql_structure`. It then joins the SQL parts into a single string + and returns this string along with the aggregated list of parameters + (primarily from the WHERE clause, as other parts like LIMIT/OFFSET are + not currently parameterized). - return sql + The collected parameters are stored in `self.params` for the instance + and also returned. - def _compile(self): - return " ".join([ensureUtf(item) for item in self.get_sql_structure() if item]) + Returns: + tuple: A tuple `(final_sql_string, all_params_list)`. + """ + self.params = [] # Reset params for this compilation run. + + sql_structure_parts, where_params = self.get_sql_structure() + self.params.extend(where_params) + + # Parameters from F objects used in _values, _order_by etc. are not handled yet. + # The current focus is on WHERE clause parameterization. + # For example, if an F object in values() was `F("CONCAT(%s, %s)")` and had its own params, + # they would need to be collected here. Current F object is simpler. + + final_sql = " ".join([ensureUtf(item) + for item in sql_structure_parts if item]) + return final_sql, self.params def __repr__(self): - return self._compile() + """ + Returns the compiled SQL string representation of the query. + + Note: This is primarily for debugging. For actual query execution, + the `sql` property should be used to get both SQL and parameters. + """ + # Per instructions, __repr__ should return the SQL string part for now. + sql_string, _ = self._compile() + return sql_string __str__ = __repr__ @property - def sql(self,): - return self.__str__() + def sql(self): + """ + The primary interface to get the compiled SQL query and its parameters. + + Returns: + tuple: A tuple `(sql_string, params_list)` ready for database execution. + """ + # This property should return the (sql_string, params_list) tuple. + return self._compile() def __or__(self, other): - return self.__class__(sql="%s UNION %s" % (self, other)) + """ + Implements the UNION operation between this Queryset and another. + + If `other` is also a Queryset, their compiled SQL strings are joined by + "UNION", and their parameter lists are concatenated. + If `other` is a string, it's treated as a raw SQL string for the UNION. + + Args: + other (Queryset or str): The Queryset or SQL string to UNION with. + + Returns: + Queryset: A new Queryset instance representing the UNION. + Note: If further filters are applied to this new Queryset, + the parameter handling for the UNIONed parts might be limited + as the UNION is currently constructed from compiled SQL strings. + """ + # Handling UNION with parameters is complex. + # If 'other' is also a Queryset, its SQL and params must be retrieved. + # Then, the params lists need to be concatenated. + # The SQL string would be "self_sql UNION other_sql". + # For now, this remains as string formatting, which is a known limitation + # if either side of UNION has parameters that are not part of this string. + # This is acceptable given the primary focus on WHERE clause injection. + + # If self.sql or other.sql now returns (s, p), this needs adjustment. + # self.sql is now a property returning (s,p) + self_sql_str, self_params = self.sql + + if isinstance(other, self.__class__): # other is also a Queryset + other_sql_str, other_params = other.sql + # Combine SQL strings + union_sql_str = "%s UNION %s" % (self_sql_str, other_sql_str) + # Create a new Queryset with this combined SQL + # The parameters would be self_params + other_params + # However, the Queryset constructor with raw SQL doesn't currently take params. + # This is a limitation we'll accept for now. + # The created Queryset will not have the combined params correctly associated + # if it's further modified. + new_qs = self.__class__(sql=union_sql_str, sql_mode=self.sql_mode) + # Manually set params for the new Queryset for this specific UNION case. + # This is a bit of a hack, ideally constructor should handle it. + new_qs.params = self_params + other_params + return new_qs + else: # other is likely a string or something not a Queryset + # Original behavior, assuming other is a string of SQL + return self.__class__( + sql="%s UNION %s" % + (self_sql_str, str(other))) class Queryset(SQLCompiler, SQLQuery): - pass + """ + The main user-facing class for building and representing an SQL query. + + It inherits query construction capabilities from `SQLQuery` and compilation + logic from `SQLCompiler`. Queryset instances are designed to be immutable; + most methods that modify the query (e.g., `filter`, `values`) return a new + Queryset instance. + + Typical usage involves creating a Queryset for a table and then chaining + methods to define the query: + ```python + qs = Queryset("users").filter(age__gt=18).values("name", "email").order_by("name") + sql_string, params = qs.sql # Get the SQL and parameters + # Execute with a database cursor: cursor.execute(sql_string, params) + ``` + """ + def __init__(self, table=None, sql_mode="MYSQL", sql=None, **kwargs): + """ + Initializes a Queryset instance. + + Args: + table (str, optional): The name of the database table. + sql_mode (str, optional): The SQL dialect/mode (e.g., "MYSQL", "SQL_SERVER"). + Defaults to "MYSQL". + sql (str, optional): An initial raw SQL string. If provided, the Queryset + can represent a more complex starting query. + Associated parameters can be passed via `kwargs['params']`. + **kwargs: Additional arguments. `params` can be passed here if `sql` is provided, + representing the parameters for that raw SQL. + """ + # Initialize SQLQuery parts (including _filters, _excludes as Q + # objects) + SQLQuery.__init__( + self, + table=table, + sql_mode=sql_mode, + sql=sql, + **kwargs) + # SQLCompiler parts don't have a separate __init__ typically. + # self.params is initialized in SQLQuery's __init__ now. + if sql and kwargs.get('params'): # If raw SQL and params are passed + self.params = kwargs.get('params') class SQLModel(object): + """ + A base class for model-like objects that can integrate with Queryset. + + This class provides a convenient way to associate a Queryset with a "model" + by defining a `table` name and optionally an `sql_mode` at the class level. + The `objects` class property then acts as a manager, returning a new + Queryset instance for that model's table. + + Example: + ```python + class User(SQLModel): + table = "auth_user" + sql_mode = "POSTGRESQL" + + # Get a Queryset for the User model + user_queryset = User.objects.filter(is_active=True) + ``` + """ @classproperty def objects(cls): + """ + A class property that acts as a manager, returning a Queryset for the model. + + It uses the `table` and `sql_mode` attributes defined on the class. + + Args: + cls: The class itself. + + Returns: + Queryset: A new Queryset instance configured for the model's table and SQL mode. + """ return Queryset(cls.table, getattr(cls, 'sql_mode', None)) diff --git a/sqlquerybuilder/__pycache__/__init__.cpython-310.pyc b/sqlquerybuilder/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2159009cb39d23b206d998a917407908f4851759 GIT binary patch literal 38279 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 literal 0 HcmV?d00001 diff --git a/sqlquerybuilder/tests.py b/sqlquerybuilder/tests.py index 3874a0e..b89c4d4 100644 --- a/sqlquerybuilder/tests.py +++ b/sqlquerybuilder/tests.py @@ -1,4 +1,12 @@ #encoding: utf-8 +""" +Unit tests for the sqlquerybuilder module. + +This module contains a comprehensive suite of tests for verifying the +functionality of the SQL query builder library, including Q objects, +Queryset operations, SQL compilation, and parameter generation for +preventing SQL injection. +""" from __future__ import unicode_literals import unittest import datetime @@ -6,100 +14,299 @@ class TestSqlBuilder(unittest.TestCase): + """ + Test suite for the SQL query builder components. + + This class tests the functionality of Q objects for condition building, + Queryset methods for constructing complex queries, and the SQL compilation + process, including correct SQL string generation with placeholders and + the associated parameter lists. + """ def test_q(self): - self.assertEqual(str(Q(a=1)), "(a=1)") - self.assertEqual(str(Q(a=1) & ~Q(b=2)), "((a=1) AND NOT (b=2))") - self.assertEqual(str(Q(nombre="jose")), "(nombre='jose')") - self.assertEqual(str(Q(a__isnull=True)), "(a is NULL)") - self.assertEqual(str(Q(a__isnull=False)), "(a is NOT NULL)") + """ + Tests the basic functionality of Q objects. + + This includes creating simple Q objects, combining them with AND (&) + and NOT (~) operators, and verifying the generated SQL string and + parameter lists for various lookup types (exact, isnull, startswith, + endswith, contains, and their case-insensitive versions). + """ + q_obj = Q(a=1) + sql, params = q_obj._compile() + self.assertEqual(sql, "(a = %s)") + self.assertEqual(params, [1]) + + q_obj = Q(a=1) & ~Q(b=2) + sql, params = q_obj._compile() + self.assertEqual(sql, "((a = %s) AND NOT (b = %s))") + self.assertEqual(params, [1, 2]) + + q_obj = Q(nombre="jose") + sql, params = q_obj._compile() + self.assertEqual(sql, "(nombre = %s)") + self.assertEqual(params, ["jose"]) + + q_obj = Q(a__isnull=True) + sql, params = q_obj._compile() + self.assertEqual(sql, "(a IS NULL)") + self.assertEqual(params, []) + + q_obj = Q(a__isnull=False) + sql, params = q_obj._compile() + self.assertEqual(sql, "(a IS NOT NULL)") + self.assertEqual(params, []) + + q_obj = Q(a__startswith="a") + sql, params = q_obj._compile() + self.assertEqual(sql, "(a LIKE BINARY %s)") + self.assertEqual(params, ["a%"]) - self.assertEqual(str(Q(a__startswith="a")), "(a LIKE BINARY 'a%')") - self.assertEqual(str(Q(a__istartswith="a")), "(a LIKE 'a%')") + q_obj = Q(a__istartswith="a") + sql, params = q_obj._compile() + self.assertEqual(sql, "(a LIKE %s)") + self.assertEqual(params, ["a%"]) - self.assertEqual(str(Q(a__endswith="a")), "(a LIKE BINARY '%a')") - self.assertEqual(str(Q(a__iendswith="a")), "(a LIKE '%a')") + q_obj = Q(a__endswith="a") + sql, params = q_obj._compile() + self.assertEqual(sql, "(a LIKE BINARY %s)") + self.assertEqual(params, ["%a"]) - self.assertEqual(str(Q(a__contains="a")), "(a LIKE BINARY '%a%')") - self.assertEqual(str(Q(a__icontains="a")), "(a LIKE '%a%')") + q_obj = Q(a__iendswith="a") + sql, params = q_obj._compile() + self.assertEqual(sql, "(a LIKE %s)") + self.assertEqual(params, ["%a"]) + + q_obj = Q(a__contains="a") + sql, params = q_obj._compile() + self.assertEqual(sql, "(a LIKE BINARY %s)") + self.assertEqual(params, ["%a%"]) + + q_obj = Q(a__icontains="a") + sql, params = q_obj._compile() + self.assertEqual(sql, "(a LIKE %s)") + self.assertEqual(params, ["%a%"]) def test_dates(self): - date = datetime.date(2010, 1, 15) - self.assertEqual(str(Q(fecha=date)), "(fecha='2010-01-15')") + """ + Tests Q object functionality with date and datetime values. + + Verifies that date and datetime objects are correctly parameterized and + that date-specific lookups (like year__lte, year) generate the + correct SQL (using DATEPART for compatibility) and parameters. + """ + date_val = datetime.date(2010, 1, 15) + q_obj = Q(fecha=date_val) + sql, params = q_obj._compile() + self.assertEqual(sql, "(fecha = %s)") + self.assertEqual(params, [date_val]) + + datetime_val = datetime.datetime(2010, 1, 15, 23, 59, 38) + q_obj = Q(fecha=datetime_val) + sql, params = q_obj._compile() + self.assertEqual(sql, "(fecha = %s)") + self.assertEqual(params, [datetime_val]) - date = datetime.datetime(2010, 1, 15, 23, 59, 38) - self.assertEqual(str(Q(fecha=date)), "(fecha='2010-01-15 23:59:38')") + # For DATEPART, the value being compared should be a parameter + q_obj = Q(fecha__year__lte=2012) + sql, params = q_obj._compile() + self.assertEqual(sql, "(DATEPART(year, fecha) <= %s)") + self.assertEqual(params, [2012]) - self.assertEqual(str(Q(fecha__year__lte=2012)), "(DATEPART(year, fecha)<=2012)") - self.assertEqual(str(Q(fecha__year=2012)), "(DATEPART(year, fecha)=2012)") + q_obj = Q(fecha__year=2012) + sql, params = q_obj._compile() + self.assertEqual(sql, "(DATEPART(year, fecha) = %s)") + self.assertEqual(params, [2012]) def test_limits(self): - self.assertEqual(Queryset("table")[:10].get_limits(), "LIMIT 10") - self.assertEqual(Queryset("table")[1:10].get_limits(), "LIMIT 9 OFFSET 1") + """ + Tests the limit and offset functionality of Queryset. + + Verifies that slicing a Queryset correctly generates the LIMIT and OFFSET + clauses in the SQL. It also checks that the `.get_limits()` helper method + returns the correct string fragment and that the overall compiled query + (SQL and params) is accurate. Parameters are not expected from limits themselves. + """ + # .get_limits() is an internal helper for SQL string construction, + # it does not deal with parameters directly. No change needed here if it only returns strings. + # However, the overall Queryset().sql will be (sql, params) + qs = Queryset("table")[:10] + self.assertEqual(qs.get_limits(), "LIMIT 10") + sql, params = qs.sql + self.assertEqual(sql, "SELECT * FROM table LIMIT 10") + self.assertEqual(params, []) + + + qs = Queryset("table")[1:10] + self.assertEqual(qs.get_limits(), "LIMIT 9 OFFSET 1") + sql, params = qs.sql + self.assertEqual(sql, "SELECT * FROM table LIMIT 9 OFFSET 1") + self.assertEqual(params, []) + def test_compound(self): + """ + Tests building more complex queries with multiple clauses. + + This includes filtering, ordering, using F objects (for raw SQL + expressions like 'now()'), and slicing for limits, for both + SQL Server and default (MySQL/PostgreSQL-like) SQL modes. + Verifies the generated SQL string and parameter lists. + """ qs = Queryset("users", "SQL_SERVER")\ .filter(nombre="jose")\ .order_by("nombre", "-fecha")\ - .filter(fecha__lte=F("now()"))[:10] + .filter(fecha__lte=F("now()"))[:10] # F("now()") is not parameterized + sql, params = qs.sql self.assertEqual( - str(qs), "SELECT TOP 10 * FROM users WHERE ((nombre='jose') AND (fecha<=now())) ORDER BY nombre, fecha DESC") + sql, "SELECT TOP 10 * FROM users WHERE ((nombre = %s) AND (fecha <= now())) ORDER BY nombre, fecha DESC") + self.assertEqual(params, ["jose"]) qs = Queryset("users")\ .filter(nombre="jose")\ .order_by( "nombre", "-fecha")\ .filter(fecha__lte=F("now()"))[:10] + sql, params = qs.sql self.assertEqual( - str(qs), "SELECT * FROM users WHERE ((nombre='jose') AND (fecha<=now())) ORDER BY nombre, fecha DESC LIMIT 10") + sql, "SELECT * FROM users WHERE ((nombre = %s) AND (fecha <= now())) ORDER BY nombre, fecha DESC LIMIT 10") + self.assertEqual(params, ["jose"]) def test_vars(self): - sql = Queryset("users") - sql = sql.filter(name="jhon") - sql = sql.exclude(date__year__lte=1977) - sql = sql.values("name", "date") - self.assertEqual( - str(sql), "SELECT name, date FROM users WHERE ((name='jhon') AND NOT (DATEPART(year, date)<=1977))") + """ + Tests query construction with chained filter, exclude, and values calls. + + Verifies that conditions are correctly ANDed, NOTed (for exclude), + and that parameters are aggregated in the correct order. Also checks + that selected columns are reflected in the final SQL. + """ + sql_qs = Queryset("users") + sql_qs = sql_qs.filter(name="jhon") + sql_qs = sql_qs.exclude(date__year__lte=1977) + sql_qs = sql_qs.values("name", "date") - sql = sql.values("name", "date", "tlf") - sql.filter(name="not") - sql.filter(name="not") + sql, params = sql_qs.sql self.assertEqual( - str(sql), "SELECT name, date, tlf FROM users WHERE ((name='jhon') AND NOT (DATEPART(year, date)<=1977))") + sql, "SELECT name, date FROM users WHERE ((name = %s) AND NOT (DATEPART(year, date) <= %s))") + self.assertEqual(params, ["jhon", 1977]) + + # Test that filter() is ANDed correctly and params are aggregated + # Test that filter() is ANDed correctly and params are aggregated + # Re-assign sql_qs after .values() to ensure further filters apply to the correct object state + sql_qs = sql_qs.values("name", "date", "tlf") + sql_after_values, params_after_values = sql_qs.sql + + # Now add another filter + sql_qs_filtered = sql_qs.filter(name="not") + sql_f, params_f = sql_qs_filtered.sql + + # Parameters should be from the state after exclude() and the new filter() + # Initial: name="jhon" -> ["jhon"] + # Exclude: date__year__lte=1977 -> ["jhon", 1977] + # Parameters order will be from _filters then _excludes. + # _filters is (Q(name="jhon") & Q(name="not")), so params ["jhon", "not"] + # _excludes is Q(date__year__lte=1977), so params [1977] for the NOT part. + # So, combined params from (_filters & ~_excludes) should be ["jhon", "not", 1977]. + expected_params = ["jhon", "not", 1977] + # The SQL structure is ((filter1 AND filter2) AND NOT exclude1) + expected_sql_substring = "(((name = %s) AND (name = %s)) AND NOT (DATEPART(year, date) <= %s))" + self.assertTrue(expected_sql_substring in sql_f) + self.assertEqual(params_f, expected_params) + + # Verify the selected columns are correct after re-assigning sql_qs + self.assertTrue(sql_f.startswith("SELECT name, date, tlf FROM users")) + def test_extra(self): - sql = Queryset("users").values("name", "date", "tlf") - sql = sql.extra({'select': 'count(*) as total'}) + """ + Tests the `extra()` method for adding raw SQL snippets. + + Confirms that `extra()` can be used to add custom selections and + WHERE clauses. It also notes that these raw SQL snippets are not + parameterized by the current refactoring, which focuses on Q objects. + Parameters are expected to be empty for queries solely using `extra`. + """ + # .extra() method as currently implemented seems to inject raw SQL. + # This test might highlight that .extra() is not subject to parameterization by this refactoring. + # This is acceptable as the focus is on Q objects. + sql_qs = Queryset("users").values("name", "date", "tlf") + sql_qs = sql_qs.extra({'select': 'count(*) as total'}) + + sql, params = sql_qs.sql self.assertEqual( - str(sql), "SELECT name, date, tlf, count(*) as total FROM users") + sql, "SELECT name, date, tlf, count(*) as total FROM users") + self.assertEqual(params, []) # No params from .extra select + + sql_qs = Queryset("users") + sql_qs = sql_qs.extra(where=["id=1", "name='jose'"]) # These are raw SQL, not parameterized + sql_qs = sql_qs.extra(select="count(*) as total") - sql = Queryset("users") - sql = sql.extra(where=["id=1", "name='jose'"]) - sql = sql.extra(select="count(*) as total") + sql, params = sql_qs.sql + # The 'extra' where clauses are joined by AND with Q-generated clauses. + # Since there are no Q-generated clauses here, it's just the extra "WHERE id=1 AND name='jose'" self.assertEqual( - str(sql), "SELECT *, count(*) as total FROM users WHERE id=1 AND name='jose'") + sql, "SELECT *, count(*) as total FROM users WHERE id=1 AND name='jose'") + self.assertEqual(params, []) - sql = sql.values(*[]) + sql_qs = sql_qs.values(*[]) # Clear values to only select the extra + sql, params = sql_qs.sql self.assertEqual( - str(sql), "SELECT count(*) as total FROM users WHERE id=1 AND name='jose'") + sql, "SELECT count(*) as total FROM users WHERE id=1 AND name='jose'") + self.assertEqual(params, []) def test_in(self,): - sql = Queryset("users") - sql = sql.filter(name__in=["jose", "andres"]) + """ + Tests the 'in' lookup type for WHERE clauses. + + Verifies correct SQL generation (e.g., "col IN (%s, %s)") and parameter + collection for lists of values. + Also tests using a subquery (another Queryset) in an IN clause, + ensuring the subquery's SQL is correctly embedded and its parameters + are prepended to the main query's parameters. + Additionally, tests IN clauses with F objects and lists containing F objects. + """ + sql_qs = Queryset("users") + sql_qs = sql_qs.filter(name__in=["jose", "andres"]) + sql, params = sql_qs.sql self.assertEqual( - str(sql), "SELECT * FROM users WHERE (name in ('jose', 'andres'))") + sql, "SELECT * FROM users WHERE (name IN (%s, %s))") + self.assertEqual(params, ["jose", "andres"]) - sql = Queryset("users") - sql = sql.filter(year__in=[2012, 2014, "Jose"]) - self.assertEqual(str(sql), "SELECT * FROM users WHERE (year in (2012, 2014, 'Jose'))") + sql_qs = Queryset("users") + sql_qs = sql_qs.filter(year__in=[2012, 2014, "Jose"]) # Mixed types + sql, params = sql_qs.sql + self.assertEqual(sql, "SELECT * FROM users WHERE (year IN (%s, %s, %s))") + self.assertEqual(params, [2012, 2014, "Jose"]) - user = Queryset("users").filter(id=100).values("id") - self.assertEqual(str(user), "SELECT id FROM users WHERE (id=100)") + # Test with a subquery from another Queryset for IN clause + # The inner Queryset (user_qs) will produce its own SQL and params + # The outer Queryset (invoices_qs) should embed the inner SQL + # and incorporate its params before its own. + user_qs = Queryset("users").filter(id=100).values("id") + user_sql, user_params = user_qs.sql + self.assertEqual(user_sql, "SELECT id FROM users WHERE (id = %s)") + self.assertEqual(user_params, [100]) - invoices = Queryset("invoices").filter(user_id__in=user) - self.assertEqual( - str(invoices), "SELECT * FROM invoices WHERE (user_id in (SELECT id FROM users WHERE (id=100)))") + invoices_qs = Queryset("invoices").filter(user_id__in=user_qs) + inv_sql, inv_params = invoices_qs.sql + expected_inv_sql = "SELECT * FROM invoices WHERE (user_id IN (SELECT id FROM users WHERE (id = %s)))" + self.assertEqual(inv_sql, expected_inv_sql) + self.assertEqual(inv_params, [100]) # Params from the inner query + + # Test IN with an F object (not typically parameterized itself, but value of F might be) + # This specific F object usage (column name) doesn't add params + invoices_qs_f = Queryset("invoices").filter(user_id__in=F('some_column')) + inv_f_sql, inv_f_params = invoices_qs_f.sql + self.assertEqual(inv_f_sql, "SELECT * FROM invoices WHERE (user_id IN (some_column))") + self.assertEqual(inv_f_params, []) + + # Test IN with a list containing an F object + # The F object is part of the SQL, other items are params + complex_in_qs = Queryset("data").filter(val__in=[1, F('another_col'), 3]) + ci_sql, ci_params = complex_in_qs.sql + self.assertEqual(ci_sql, "SELECT * FROM data WHERE (val IN (%s, another_col, %s))") + self.assertEqual(ci_params, [1, 3]) if __name__ == '__main__': unittest.main()