Merge pull request #56040 from mihir-kandoi/pg-fn-swap

fix(postgres): replace MySQL-only SQL functions with portable equivalents
This commit is contained in:
Mihir Kandoi
2026-06-17 15:40:43 +05:30
committed by GitHub
8 changed files with 110 additions and 26 deletions

View File

@@ -3,7 +3,7 @@
import frappe
from frappe import _, qb
from frappe.query_builder import CustomFunction
from frappe.query_builder import Case
from frappe.query_builder.custom import ConstantColumn
@@ -93,7 +93,6 @@ def get_amounts_not_reflected_in_system_for_bank_reconciliation_statement(filter
.run(as_dict=1)
)
ifelse = CustomFunction("IF", ["condition", "then", "else"])
pe = qb.DocType("Payment Entry")
doctype_name = ConstantColumn("Payment Entry")
payments = (
@@ -101,7 +100,10 @@ def get_amounts_not_reflected_in_system_for_bank_reconciliation_statement(filter
.select(
doctype_name.as_("doctype"),
pe.name,
ifelse(pe.paid_from.eq(filters.account), pe.paid_amount, pe.received_amount).as_("amount"),
Case()
.when(pe.paid_from.eq(filters.account), pe.paid_amount)
.else_(pe.received_amount)
.as_("amount"),
pe.payment_type,
pe.party_type,
pe.posting_date,

View File

@@ -0,0 +1,24 @@
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
import frappe
from frappe.utils import nowdate
from erpnext.accounts.report.cheques_and_deposits_incorrectly_cleared.cheques_and_deposits_incorrectly_cleared import (
execute,
)
from erpnext.tests.utils import ERPNextTestSuite
class TestChequesAndDepositsIncorrectlyCleared(ERPNextTestSuite):
def test_report_executes_with_case_amount(self):
# Exercises the Payment Entry branch whose amount column uses a db-aware CASE expression
# (previously a MySQL-only IF()). IF() does not compile on postgres, so running the report
# query guards the portability fix on both databases.
company = frappe.db.get_value("Company", {}, "name")
account = frappe.db.get_value(
"Account", {"account_type": "Bank", "company": company, "is_group": 0}, "name"
)
columns, data = execute(frappe._dict({"account": account, "report_date": nowdate()}))
self.assertTrue(columns)
self.assertIsInstance(data, list)

View File

@@ -4,7 +4,7 @@
import frappe
from frappe import _
from frappe.query_builder import CustomFunction
from frappe.query_builder.functions import CurDate, DateDiff
from frappe.utils import cint
@@ -102,11 +102,11 @@ def get_sales_details(filters):
child_doctype = "Sales Order Item" if filters["based_on"] == "Sales Order" else "Sales Invoice Item"
child = frappe.qb.DocType(child_doctype)
date_diff = CustomFunction("DATEDIFF", ["d1", "d2"])
current_date = CustomFunction("CURRENT_DATE", [])
date_col = parent.transaction_date if filters["based_on"] == "Sales Order" else parent.posting_date
days_since_last_order = date_diff(current_date(), date_col)
# DateDiff is cross-database (DATEDIFF on MariaDB, date subtraction on postgres); CurDate()
# renders the bare CURRENT_DATE keyword. Yields the integer number of days.
days_since_last_order = DateDiff(CurDate(), date_col)
sales_data = (
frappe.qb.from_(parent)

View File

@@ -0,0 +1,32 @@
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
import frappe
from frappe.utils import add_days, today
from erpnext.accounts.report.inactive_sales_items.inactive_sales_items import execute
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.tests.utils import ERPNextTestSuite
class TestInactiveSalesItems(ERPNextTestSuite):
def test_days_since_last_order_is_computed(self):
# Exercises the date-arithmetic path (DATEDIFF/CURRENT_DATE on mariadb, date subtraction on
# postgres) which must produce the same integer day count on both databases.
item = make_item("_Test Inactive Sales Item").name
old_date = add_days(today(), -120)
so = make_sales_order(item=item, qty=3, rate=150, transaction_date=old_date)
so.items[0].delivery_date = add_days(old_date, 7)
so.save()
so.submit()
columns, data = execute(frappe._dict({"based_on": "Sales Order", "days": 30}))
self.assertTrue(columns)
row = next((r for r in data if r.get("item") == item and r.get("days_since_last_order")), None)
self.assertIsNotNone(row, "Inactive item should appear in the report")
self.assertGreaterEqual(row["days_since_last_order"], 30)
def test_report_runs_for_sales_invoice(self):
columns, _data = execute(frappe._dict({"based_on": "Sales Invoice", "days": 30}))
self.assertTrue(columns)

View File

@@ -7,7 +7,7 @@ import sys
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.query_builder.functions import Sum
from frappe.query_builder.functions import DateDiff, Sum
from frappe.utils import getdate
@@ -68,7 +68,7 @@ def get_item_workdays(scorecard):
frappe.qb.from_(PO_Item)
.join(PO)
.on(PO_Item.parent == PO.name)
.select(Sum(frappe.qb.fn.DATEDIFF(scorecard.end_date, PO_Item.schedule_date) * (PO_Item.qty)))
.select(Sum(DateDiff(scorecard.end_date, PO_Item.schedule_date) * (PO_Item.qty)))
.where(PO.supplier == scorecard.supplier)
.where(PO_Item.received_qty < PO_Item.qty)
.where(PO_Item.schedule_date[scorecard.start_date : scorecard.end_date]) # Équivalent du BETWEEN
@@ -153,7 +153,7 @@ def get_total_days_late(scorecard):
.on(PR_Item.purchase_order_item == PO_Item.name)
.join(PO)
.on(PO_Item.parent == PO.name)
.select(Sum(frappe.qb.fn.DATEDIFF(PR.posting_date, PO_Item.schedule_date) * PR_Item.qty))
.select(Sum(DateDiff(PR.posting_date, PO_Item.schedule_date) * PR_Item.qty))
.where(PO.supplier == scorecard.supplier)
.where(PO_Item.schedule_date[scorecard.start_date : scorecard.end_date])
.where(PO_Item.schedule_date < PR.posting_date)
@@ -170,10 +170,7 @@ def get_total_days_late(scorecard):
.join(PO)
.on(PO_Item.parent == PO.name)
.select(
Sum(
frappe.qb.fn.DATEDIFF(scorecard.end_date, PO_Item.schedule_date)
* (PO_Item.qty - PO_Item.received_qty)
)
Sum(DateDiff(scorecard.end_date, PO_Item.schedule_date) * (PO_Item.qty - PO_Item.received_qty))
)
.where(PO.supplier == scorecard.supplier)
.where(PO_Item.received_qty < PO_Item.qty)
@@ -530,7 +527,7 @@ def get_rfq_response_days(scorecard):
.on(sq_item.request_for_quotation_item == rfq_item.name)
.join(sq)
.on(sq_item.parent == sq.name)
.select(frappe.qb.fn.Sum(frappe.qb.fn.Datediff(sq.transaction_date, rfq.transaction_date)))
.select(frappe.qb.fn.Sum(DateDiff(sq.transaction_date, rfq.transaction_date)))
.where(rfq_sup.supplier == scorecard.supplier)
.where(sq.supplier == scorecard.supplier)
.where(rfq.transaction_date[scorecard.start_date : scorecard.end_date])

View File

@@ -5,8 +5,7 @@
import frappe
from frappe import _
from frappe.query_builder import DocType
from frappe.query_builder.custom import GROUP_CONCAT
from frappe.query_builder.functions import Date
from frappe.query_builder.functions import Date, GroupConcat
Opportunity = DocType("Opportunity")
OpportunityLostReasonDetail = DocType("Opportunity Lost Reason Detail")
@@ -72,6 +71,9 @@ def get_columns():
def get_data(filters):
# db-aware GROUP_CONCAT (MariaDB) / STRING_AGG (postgres) with a ", " separator
lost_reasons = GroupConcat(OpportunityLostReasonDetail.lost_reason, ", ", alias="lost_reason")
query = (
frappe.qb.from_(Opportunity)
.left_join(OpportunityLostReasonDetail)
@@ -85,7 +87,7 @@ def get_data(filters):
Opportunity.party_name,
Opportunity.customer_name,
Opportunity.opportunity_type,
GROUP_CONCAT(OpportunityLostReasonDetail.lost_reason, alias="lost_reason").separator(", "),
lost_reasons,
Opportunity.sales_stage,
Opportunity.territory,
)

View File

@@ -0,0 +1,22 @@
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
import frappe
from frappe.utils import add_days, today
from erpnext.crm.report.lost_opportunity.lost_opportunity import execute
from erpnext.tests.utils import ERPNextTestSuite
class TestLostOpportunity(ERPNextTestSuite):
def test_report_aggregates_lost_reasons(self):
# Exercises the db-aware GROUP_CONCAT (MariaDB) / STRING_AGG (postgres) aggregation of the
# child "Opportunity Lost Reason Detail" rows. The MySQL-only GROUP_CONCAT term would fail to
# compile on postgres, so simply running the report query guards the portability fix on both
# databases.
company = frappe.db.get_value("Company", {}, "name")
columns, data = execute(
frappe._dict({"company": company, "from_date": add_days(today(), -365), "to_date": today()})
)
self.assertTrue(columns)
self.assertIsInstance(data, list)

View File

@@ -11,6 +11,7 @@ are called from other modules.
import frappe
from dateutil.relativedelta import relativedelta
from frappe import _
from frappe.query_builder.functions import CombineDatetime
from frappe.utils import (
cint,
date_diff,
@@ -268,13 +269,17 @@ class OperationsService:
self.doc.actual_end_date = max(end_dates)
def _set_dates_from_stock_entries(self):
data = frappe.get_all(
"Stock Entry",
fields=[{"TIMESTAMP": ["posting_date", "posting_time"], "as": "posting_datetime"}],
filters={
"work_order": self.doc.name,
"purpose": ("in", ["Material Transfer for Manufacture", "Manufacture"]),
},
# {"TIMESTAMP": [...]} renders MySQL's TIMESTAMP(date, time), invalid on postgres; use the
# portable CombineDatetime via query builder instead.
se = frappe.qb.DocType("Stock Entry")
data = (
frappe.qb.from_(se)
.select(CombineDatetime(se.posting_date, se.posting_time).as_("posting_datetime"))
.where(
(se.work_order == self.doc.name)
& (se.purpose.isin(["Material Transfer for Manufacture", "Manufacture"]))
)
.run(as_dict=True)
)
if not data:
return