# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: MIT. See LICENSE """Query implementation using frappe's query builder""" import copy import json from typing import Any import frappe from frappe.database.utils import DefaultOrderBy, FilterValue from frappe.deprecation_dumpster import deprecation_warning from frappe.model.utils import is_virtual_doctype from frappe.model.utils.user_settings import get_user_settings, update_user_settings from frappe.query_builder.utils import Column from frappe.utils import sbool class DatabaseQuery: """ Copy of db_query.py DatabaseQuery, using query builder instead. """ def __init__(self, doctype: str) -> None: self.doctype = doctype def execute( self, fields: list[str] | tuple[str, ...] | str | None = None, filters: dict[str, FilterValue] | FilterValue | list[list | FilterValue] | None = None, or_filters: dict[str, FilterValue] | FilterValue | list[list | FilterValue] | None = None, group_by: str | None = None, order_by: str = DefaultOrderBy, limit: int | None = None, offset: int | None = None, limit_start: int = 0, limit_page_length: int | None = None, as_list: bool = False, with_childnames: bool = False, debug: bool = False, ignore_permissions: bool = False, user: str | None = None, with_comment_count: bool = False, join: str = "left join", distinct: bool = False, start: int | None = None, page_length: int | None = None, ignore_ifnull: bool = False, save_user_settings: bool = False, save_user_settings_fields: bool = False, update: dict[str, Any] | None = None, user_settings: str | dict[str, Any] | None = None, reference_doctype: str | None = None, run: bool = True, strict: bool = True, pluck: str | None = None, ignore_ddl: bool = False, *, parent_doctype: str | None = None, ignore_user_permissions: bool = False, ) -> list: """Execute a database query using the Query Builder engine. Args: fields: Fields to select. Can be a list, tuple, or comma-separated string. filters: Main filter conditions. Supports dicts, lists, and operator tuples. or_filters: Additional filter conditions to be combined with OR. group_by: Fields to group results by. order_by: Fields to order results by. limit: Maximum number of records to return. offset: Number of records to skip for pagination. limit_start: Legacy pagination start (deprecated, use offset). limit_page_length: Legacy pagination length (deprecated, use limit). as_list: Return results as list of lists instead of list of dicts. with_childnames: Include child document names (not implemented). debug: Enable debug mode for query inspection. ignore_permissions: Skip permission checks for the query. user: Execute query as specific user. with_comment_count: Add comment count to results (_comment_count field). join: Type of join for related tables (QB engine auto-determines optimal joins). distinct: Return only distinct results. start: Legacy alias for limit_start (deprecated). page_length: Legacy alias for limit_page_length (deprecated). ignore_ifnull: Skip IFNULL wrapping (QB engine handles NULL optimization automatically). save_user_settings: Save current query settings for user. save_user_settings_fields: Save field selection in user settings. update: Dictionary to merge into each result when as_list=False. user_settings: Custom user settings as JSON string or dict. reference_doctype: Reference doctype for contextual user permissions. run: Execute query immediately (True) or return query object (False). strict: Enable strict mode for query validation (legacy compatibility). pluck: Extract single field values as a simple list. ignore_ddl: Ignore DDL operations during query execution (legacy compatibility). parent_doctype: Parent doctype for child table queries. ignore_user_permissions: Ignore user permissions for the query. Useful for link search queries when the link field has `ignore_user_permissions` set. Returns: Query results as list of dicts (default) or list of lists (as_list=True). If pluck is specified, returns list of field values. If run=False, returns query object instead of results. Raises: ValidationError: For invalid parameters or query structure. PermissionError: When user lacks required permissions. """ # filters and fields swappable # its hard to remember what comes first if isinstance(fields, dict) or (fields and isinstance(fields, list) and isinstance(fields[0], list)): # if fields is given as dict/list of list, its probably filters filters, fields = fields, filters elif fields and isinstance(filters, list) and len(filters) > 1 and isinstance(filters[0], str): # if `filters` is a list of strings, its probably fields filters, fields = fields, filters # Set fields to the requested field or `name` if none specified if not fields: fields = [pluck or "name"] self.fields = fields # Handle virtual doctypes before any other processing if is_virtual_doctype(self.doctype): return self._handle_virtual_doctype( fields, filters, or_filters, start, offset, limit_start, page_length, limit, limit_page_length, order_by, as_list, with_comment_count, save_user_settings, save_user_settings_fields, pluck, parent_doctype, ) # Handle deprecated parameters if limit_start: deprecation_warning( "2024-01-01", "v17", "The 'limit_start' parameter is deprecated. Use 'offset' instead." ) if offset is None: offset = limit_start if limit_page_length: deprecation_warning( "2024-01-01", "v17", "The 'limit_page_length' parameter is deprecated. Use 'limit' instead." ) if limit is None: limit = limit_page_length if start: deprecation_warning( "2024-01-01", "v17", "The 'start' parameter is deprecated. Use 'offset' instead." ) if offset is None: offset = start if page_length: deprecation_warning( "2024-01-01", "v17", "The 'page_length' parameter is deprecated. Use 'limit' instead." ) if limit is None: limit = page_length # Check if table exists before running query from frappe.model.meta import get_table_columns try: get_table_columns(self.doctype) except frappe.db.TableMissingError: if ignore_ddl: return [] else: raise # Build query using QB engine with converted syntax kwargs = { "table": self.doctype, "fields": fields, "filters": filters, "or_filters": or_filters, "group_by": group_by, "order_by": order_by, "limit": frappe.cint(limit), "offset": frappe.cint(offset), "distinct": distinct, "ignore_permissions": ignore_permissions, "ignore_user_permissions": ignore_user_permissions, "user": user, "parent_doctype": parent_doctype, "reference_doctype": reference_doctype, "db_query_compat": True, } query = frappe.qb.get_query(**kwargs) if not run: return query # Run the query if pluck: result = query.run(debug=debug, as_dict=True, pluck=pluck) else: result = query.run(debug=debug, as_dict=not as_list, update=update) # Add comment count if requested and not as_list if sbool(with_comment_count) and not as_list and self.doctype: self._add_comment_count(result) # Save user settings if requested if save_user_settings: user_settings_fields = copy.deepcopy(fields) if save_user_settings_fields else None if user_settings and isinstance(user_settings, str): user_settings = json.loads(user_settings) self._save_user_settings(user_settings, user_settings_fields, save_user_settings_fields) return result def _add_comment_count(self, result: list[Any]) -> None: """Add comment count to each result row by parsing _comments field. This method adds a _comment_count field to each row based on the _comments field content. It parses the JSON structure to count the number of comments. Args: result: List of result dictionaries to modify """ if not result: return for row in result: if isinstance(row, dict) and "_comments" in row: try: comments_data = json.loads(row["_comments"] or "[]") row["_comment_count"] = len(comments_data) if isinstance(comments_data, list) else 0 except (json.JSONDecodeError, TypeError): row["_comment_count"] = 0 elif isinstance(row, dict): row["_comment_count"] = 0 def _save_user_settings( self, user_settings: dict[str, Any] | None, user_settings_fields: list[str] | None, save_user_settings_fields: bool, ) -> None: """Save user settings for the current query. This method stores user preferences for field selections and other query parameters to provide a personalized experience for repeated queries. Args: user_settings: Custom user settings to save user_settings_fields: Field list to save if save_user_settings_fields is True save_user_settings_fields: Whether to save the field selection """ if not self.doctype: return try: current_settings = get_user_settings(self.doctype) or {} # Update with custom user settings if provided if user_settings: current_settings.update(user_settings) # Save field selection if requested if save_user_settings_fields and user_settings_fields: current_settings["fields"] = user_settings_fields # Only save if there are actual settings to save if current_settings: update_user_settings(self.doctype, current_settings) except Exception: # Don't let user settings errors break the query pass def _handle_virtual_doctype( self, fields: list[str] | tuple[str, ...] | str | None, filters: dict[str, FilterValue] | FilterValue | list[list | FilterValue] | None, or_filters: dict[str, FilterValue] | FilterValue | list[list | FilterValue] | None, start: int | None, offset: int | None, limit_start: int, page_length: int | None, limit: int | None, limit_page_length: int | None, order_by: str, as_list: bool, with_comment_count: bool, save_user_settings: bool, save_user_settings_fields: bool, pluck: str | None, parent_doctype: str | None, ) -> list: """Handle virtual doctype queries by delegating to controller.get_list(). Virtual doctypes don't have database tables and use controller methods to generate data dynamically. Converts filters to Filters objects and calls the doctype controller's get_list method. Returns: List of results from controller.get_list() """ from frappe.model.base_document import get_controller from frappe.types.filter import Filters controller = get_controller(self.doctype) if not hasattr(controller, "get_list"): return [] filters = filters or Filters() if isinstance(filters, str): filters = json.loads(filters) if not isinstance(filters, Filters): filters = Filters(filters, doctype=self.doctype) or_filters = or_filters or Filters() if isinstance(or_filters, str): or_filters = json.loads(or_filters) if not isinstance(or_filters, Filters): or_filters = Filters(or_filters, doctype=self.doctype) _page_length = page_length or limit or limit_page_length or 20 kwargs = { "fields": fields, "filters": filters, "or_filters": or_filters, "start": start or offset or limit_start or 0, "page_length": _page_length, "limit_page_length": _page_length, "order_by": order_by, "as_list": as_list, "with_comment_count": with_comment_count, "save_user_settings": save_user_settings, "save_user_settings_fields": save_user_settings_fields, "pluck": pluck, "parent_doctype": parent_doctype, "doctype": self.doctype, } # Use frappe.call to filter kwargs and call controller return frappe.call(controller.get_list, args=kwargs, **kwargs)