seitime-frappe/frappe/model/qb_query.py
Akhil Narang 3a2dcaa2de
fix(with_comment_count): handle string values
Signed-off-by: Akhil Narang <me@akhilnarang.dev>
2025-12-18 18:11:02 +05:30

355 lines
12 KiB
Python

# 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)