mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-13 22:51:49 +00:00
ci(postgres): teach the PG-compat tooling the audit 6-9 divergence classes
The whole-repo MariaDB<->PostgreSQL audits surfaced classes the checker and review guide did not yet cover. Add them: Static checker (.github/helper/postgres_compat.py) - new mechanical breaks: - .rlike() / raw RLIKE: frappe rewrites REGEXP->~* on Postgres but NOT RLIKE. - Cast(x, "char") / raw CAST AS CHAR: bare CHAR is character(1) on Postgres and truncates multi-digit values; use "varchar". (Both flag zero production code; the only repo hit is in patches/, which the hook already excludes.) Greptile config + POSTGRES_COMPATIBILITY.md - new semantic/hard classes: - aggregate (Sum/Count) selected next to bare columns with no GROUP BY at all. - .like()/LIKE on a non-text column (bigint ILIKE) -> Cast_ to varchar. - get_all(fields=["CapitalCase"]) identifier-case (extends the get_value case). - bool into a Check column via qb.update().set() (extends set_value/db_set). - int/int division: float a literal (col/1440 -> col/1440.0). - Concat over a nullable column leaking a bare prefix on Postgres. - clarify REGEXP/.regexp() is translated but RLIKE/.rlike() is not.
This commit is contained in:
20
.github/helper/postgres_compat.py
vendored
20
.github/helper/postgres_compat.py
vendored
@@ -62,6 +62,10 @@ SQL_PATTERNS: list[tuple[re.Pattern, str]] = [
|
||||
"single-quoted column alias breaks on Postgres -> use a bare or double-quoted alias"),
|
||||
(re.compile(r"\bif\s*\(", re.I),
|
||||
"SQL IF() is MySQL-only -> use CASE WHEN ... THEN ... ELSE ... END (frappe.qb.Case())"),
|
||||
(re.compile(r"\brlike\b", re.I),
|
||||
"RLIKE is MySQL-only -> frappe rewrites REGEXP->~* on Postgres but NOT RLIKE; use REGEXP / .regexp() / ~"),
|
||||
(re.compile(r"\bcast\s*\([^)]*\bas\s+char\b", re.I),
|
||||
"CAST(... AS CHAR) is character(1) on Postgres and truncates -> CAST AS VARCHAR (frappe Cast_(x, 'varchar'))"),
|
||||
]
|
||||
|
||||
# UPDATE ... JOIN: both keywords in the same SQL string.
|
||||
@@ -71,6 +75,10 @@ MYSQL_RESULT_KEYS = {"Column_name", "Key_name", "Seq_in_index", "Non_unique", "I
|
||||
|
||||
SET_BOOL_FUNCS = {"set_value", "db_set"}
|
||||
|
||||
# query-builder cast helpers: pypika Cast / frappe Cast_. A "char" target type is character(1)
|
||||
# on Postgres (truncates); "varchar" is the full-length cast.
|
||||
CAST_FUNCS = {"Cast", "Cast_"}
|
||||
|
||||
# frappe.get_all / get_list: frappe's db_query SILENTLY drops ORDER BY for `distinct` queries on
|
||||
# Postgres (the ORDER BY column must appear in the SELECT-DISTINCT list), so `distinct=True` together
|
||||
# with a literal `order_by` is a no-op on PG and the result comes back unordered.
|
||||
@@ -178,6 +186,18 @@ class Visitor(ast.NodeVisitor):
|
||||
if has_distinct and has_literal_order:
|
||||
self._flag(node, f"{name}(distinct=True, order_by=...) -> frappe drops ORDER BY for distinct queries on Postgres; sort in python instead, e.g. sorted(..., key=str.casefold)")
|
||||
|
||||
# query-builder .rlike(...): pypika emits the MySQL-only RLIKE operator, which frappe does
|
||||
# NOT translate for Postgres (it rewrites only REGEXP -> ~*).
|
||||
if name == "rlike":
|
||||
self._flag(node, ".rlike() emits MySQL-only RLIKE (not translated on Postgres) -> use .regexp() (rewritten to ~*) or .like()")
|
||||
|
||||
# Cast(col, "char") / Cast_(col, "char"): on Postgres a bare CHAR is character(1) and truncates
|
||||
# (e.g. CAST(12 AS CHAR) -> '1'); use "varchar" for a full-length string cast.
|
||||
if name in CAST_FUNCS:
|
||||
for arg in (*node.args, *(kw.value for kw in node.keywords)):
|
||||
if isinstance(arg, ast.Constant) and isinstance(arg.value, str) and arg.value.strip().lower() == "char":
|
||||
self._flag(node, f"{name}(..., 'char') is character(1) on Postgres and truncates -> use 'varchar'")
|
||||
|
||||
self.generic_visit(node)
|
||||
|
||||
def visit_Subscript(self, node: ast.Subscript) -> None:
|
||||
|
||||
Reference in New Issue
Block a user