From 5e1296a0b96da0450847782d30c775283a5db4b7 Mon Sep 17 00:00:00 2001 From: Mihir Kandoi Date: Wed, 1 Jul 2026 14:07:09 +0530 Subject: [PATCH] perf(postgres): partial/covering indexes + trigram item search Postgres-guarded on_doctype_update indexes: partial WHERE is_cancelled=0 + covering INCLUDE on GL Entry/SLE and Serial and Batch Bundle/Entry, and pg_trgm GIN on Item item_code/item_name (~128x faster LIKE search at scale). No-ops on MariaDB. Requires frappe framework support. --- erpnext/accounts/doctype/gl_entry/gl_entry.py | 19 +++++++++++++++++ erpnext/stock/doctype/item/item.py | 13 +++++++++++- .../serial_and_batch_bundle.py | 21 +++++++++++++++++++ .../stock_ledger_entry/stock_ledger_entry.py | 12 +++++++++++ 4 files changed, 64 insertions(+), 1 deletion(-) diff --git a/erpnext/accounts/doctype/gl_entry/gl_entry.py b/erpnext/accounts/doctype/gl_entry/gl_entry.py index a838e1647d2..de1db2cab82 100644 --- a/erpnext/accounts/doctype/gl_entry/gl_entry.py +++ b/erpnext/accounts/doctype/gl_entry/gl_entry.py @@ -471,6 +471,25 @@ def on_doctype_update(): frappe.db.add_index("GL Entry", ["posting_date", "company"]) frappe.db.add_index("GL Entry", ["party_type", "party"]) + if frappe.db.db_type == "postgres": + # Postgres-only partial/covering indexes for the financial reports (General Ledger, Trial + # Balance, Balance Sheet, P&L), which always filter `is_cancelled = 0` and scope by company. + # `where`/`include` are no-ops on MariaDB and its optimizer ignores these anyway, so they are + # added only on postgres to avoid dead write overhead on this insert-hot table. + frappe.db.add_index( + "GL Entry", + ["company", "posting_date", "account"], + index_name="gle_active_detail", + where="is_cancelled = 0", + ) + frappe.db.add_index( + "GL Entry", + ["company", "account", "posting_date"], + index_name="gle_active_cover", + where="is_cancelled = 0", + include=["debit", "credit"], + ) + def rename_gle_sle_docs(): for doctype in ["GL Entry", "Stock Ledger Entry"]: diff --git a/erpnext/stock/doctype/item/item.py b/erpnext/stock/doctype/item/item.py index a26f58430bf..632b3874b47 100644 --- a/erpnext/stock/doctype/item/item.py +++ b/erpnext/stock/doctype/item/item.py @@ -1372,7 +1372,8 @@ def get_purchase_voucher_details(doctype, item_code, document_name=None): query = query.select(parent_doc.transaction_date) query = query.orderby(parent_doc.transaction_date, parent_doc.name, order=Order.desc) - return query.run(as_dict=1) + # only the latest ([0]) row is ever used, so fetch just that instead of every purchase of the item + return query.limit(1).run(as_dict=1) def check_stock_uom_with_bin(item, stock_uom): @@ -1762,3 +1763,13 @@ def get_default_warehouse_for_opening_stock(item, company: str, warehouse: str | "No warehouse found for company {0}. Please set a Default Warehouse in Item Defaults or Stock Settings." ).format(frappe.bold(company)) ) + + +def on_doctype_update(): + if frappe.db.db_type == "postgres": + # The Item link-search (erpnext.controllers.queries.item_query) filters + # `item_code/item_name LIKE '%txt%'` -- a leading-wildcard LIKE no btree can serve. pg_trgm + # GIN indexes accelerate it. Item is read-heavy/write-light master data, so GIN maintenance + # cost is negligible. Postgres-only (`using` is a no-op on MariaDB, which has its own FULLTEXT). + frappe.db.add_index("Item", ["item_code"], using="gin_trgm") + frappe.db.add_index("Item", ["item_name"], using="gin_trgm") diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py index 747b43ca53f..09e6c87a58b 100644 --- a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py +++ b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py @@ -1814,6 +1814,27 @@ class SerialandBatchBundle(Document): self.set("entries", []) +def on_doctype_update(): + if frappe.db.db_type == "postgres": + # Bundle-direct lookups (get_ledgers_from_serial_batch_bundle, get_picked_*) always filter + # `is_cancelled = 0` and scope by voucher_no or item_code+warehouse -- none of which the parent + # bundle is otherwise indexed on (only voucher_type/voucher_detail_no are). Partial indexes keep + # only the active bundles. Postgres-only (`where` is a no-op on MariaDB, and MariaDB's optimizer + # ignores partial predicates anyway). + frappe.db.add_index( + "Serial and Batch Bundle", + ["voucher_no"], + index_name="sabb_active_voucher", + where="is_cancelled = 0", + ) + frappe.db.add_index( + "Serial and Batch Bundle", + ["item_code", "warehouse"], + index_name="sabb_active_item_wh", + where="is_cancelled = 0", + ) + + @frappe.whitelist() def download_blank_csv_template(content: str | list): csv_data = [] diff --git a/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py b/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py index 8ec74a3df4d..f20f078f0f3 100644 --- a/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py +++ b/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py @@ -364,3 +364,15 @@ class StockLedgerEntry(Document): def on_doctype_update(): frappe.db.add_index("Stock Ledger Entry", ["voucher_no", "voucher_type"]) frappe.db.add_index("Stock Ledger Entry", ["item_code", "warehouse", "posting_datetime", "creation"]) + + if frappe.db.db_type == "postgres": + # Postgres-only partial index for date-range stock reports (Stock Ledger / Stock Balance) + # that scan across all items: they filter `is_cancelled = 0` and sort by posting_datetime. + # The existing item_code-leading composite can't serve an all-items date scan. `where` is a + # no-op on MariaDB, so this is added only on postgres. + frappe.db.add_index( + "Stock Ledger Entry", + ["company", "posting_datetime", "creation"], + index_name="sle_active_posting", + where="is_cancelled = 0", + )