Browse Source

Merge pull request #18233 from frappe/version-14-hotfix

chore: release v14
version-14
Ankush Menat 2 years ago
committed by GitHub
parent
commit
90a6725f5e
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 232 additions and 125 deletions
  1. +1
    -0
      .github/helper/consumer_db/mariadb.json
  2. +11
    -24
      frappe/__init__.py
  3. +2
    -5
      frappe/cache_manager.py
  4. +1
    -0
      frappe/commands/utils.py
  5. +0
    -4
      frappe/core/doctype/doctype/doctype.py
  6. +3
    -0
      frappe/core/doctype/prepared_report/prepared_report.py
  7. +4
    -2
      frappe/core/doctype/rq_worker/rq_worker.py
  8. +1
    -0
      frappe/custom/doctype/customize_form/customize_form.js
  9. +3
    -5
      frappe/desk/form/load.py
  10. +3
    -5
      frappe/desk/form/meta.py
  11. +3
    -0
      frappe/desk/form/save.py
  12. +2
    -11
      frappe/desk/page/setup_wizard/setup_wizard.py
  13. +3
    -0
      frappe/desk/query_report.py
  14. +61
    -53
      frappe/model/meta.py
  15. +12
    -1
      frappe/monitor.py
  16. +12
    -7
      frappe/public/js/frappe/utils/dashboard_utils.js
  17. +2
    -2
      frappe/public/js/frappe/widgets/onboarding_widget.js
  18. +4
    -0
      frappe/query_builder/functions.py
  19. +6
    -3
      frappe/query_builder/terms.py
  20. +8
    -0
      frappe/tests/test_commands.py
  21. +39
    -0
      frappe/tests/test_perf.py
  22. +43
    -0
      frappe/tests/test_query_builder.py
  23. +1
    -1
      frappe/tests/utils.py
  24. +1
    -1
      frappe/utils/__init__.py
  25. +6
    -1
      frappe/utils/data.py

+ 1
- 0
.github/helper/consumer_db/mariadb.json View File

@@ -13,5 +13,6 @@
"root_login": "root",
"root_password": "travis",
"host_name": "http://test_site:8000",
"monitor": 1,
"server_script_enabled": true
}

+ 11
- 24
frappe/__init__.py View File

@@ -239,7 +239,6 @@ def init(site: str, sites_path: str = ".", new_site: bool = False) -> None:
local.jloader = None
local.cache = {}
local.document_cache = {}
local.meta_cache = {}
local.form_dict = _dict()
local.preload_assets = {"style": [], "script": []}
local.session = _dict()
@@ -1065,21 +1064,9 @@ def set_value(doctype, docname, fieldname, value=None):
return frappe.client.set_value(doctype, docname, fieldname, value)


@overload
def get_cached_doc(doctype, docname, _allow_dict=True) -> dict:
...


@overload
def get_cached_doc(*args, **kwargs) -> "Document":
...


def get_cached_doc(*args, **kwargs):
allow_dict = kwargs.pop("_allow_dict", False)

def _respond(doc, from_redis=False):
if not allow_dict and isinstance(doc, dict):
if isinstance(doc, dict):
local.document_cache[key] = doc = get_doc(doc)

elif from_redis:
@@ -1103,6 +1090,12 @@ def get_cached_doc(*args, **kwargs):
if not key:
key = get_document_cache_key(doc.doctype, doc.name)

_set_document_in_cache(key, doc)

return doc


def _set_document_in_cache(key: str, doc: "Document") -> None:
local.document_cache[key] = doc

# Avoid setting in local.cache since we're already using local.document_cache above
@@ -1112,8 +1105,6 @@ def get_cached_doc(*args, **kwargs):
except Exception:
cache().hset("document_cache", key, doc.as_dict(), cache_locally=False)

return doc


def can_cache_doc(args) -> str | None:
"""
@@ -1152,7 +1143,7 @@ def get_cached_value(
doctype: str, name: str, fieldname: str = "name", as_dict: bool = False
) -> Any:
try:
doc = get_cached_doc(doctype, name, _allow_dict=True)
doc = get_cached_doc(doctype, name)
except DoesNotExistError:
clear_last_message()
return
@@ -1188,13 +1179,9 @@ def get_doc(*args, **kwargs) -> "Document":

doc = frappe.model.document.get_doc(*args, **kwargs)

# Replace cache
if key := can_cache_doc(args):
if key in local.document_cache:
local.document_cache[key] = doc

if cache().hexists("document_cache", key):
cache().hset("document_cache", key, doc.as_dict())
# Replace cache if stale one exists
if (key := can_cache_doc(args)) and cache().hexists("document_cache", key):
_set_document_in_cache(key, doc)

return doc



+ 2
- 5
frappe/cache_manager.py View File

@@ -59,8 +59,8 @@ user_cache_keys = (
)

doctype_cache_keys = (
"meta",
"form_meta",
"doctype_meta",
"doctype_form_meta",
"table_columns",
"last_modified",
"linked_doctypes",
@@ -117,9 +117,6 @@ def clear_doctype_cache(doctype=None):
clear_controller_cache(doctype)
cache = frappe.cache()

if getattr(frappe.local, "meta_cache") and (doctype in frappe.local.meta_cache):
del frappe.local.meta_cache[doctype]

for key in ("is_table", "doctype_modules", "document_cache"):
cache.delete_value(key)



+ 1
- 0
frappe/commands/utils.py View File

@@ -881,6 +881,7 @@ def run_ui_tests(
"@4tw/cypress-drag-drop@^2",
"cypress-real-events",
"@testing-library/cypress@^8",
"@testing-library/dom@8.17.1",
"@cypress/code-coverage@^3",
]
)


+ 0
- 4
frappe/core/doctype/doctype/doctype.py View File

@@ -414,10 +414,6 @@ class DocType(Document):
if not frappe.flags.in_install and hasattr(self, "before_update"):
self.sync_global_search()

# clear from local cache
if self.name in frappe.local.meta_cache:
del frappe.local.meta_cache[self.name]

clear_linked_doctype_cache()

def setup_autoincrement_and_sequence(self):


+ 3
- 0
frappe/core/doctype/prepared_report/prepared_report.py View File

@@ -8,6 +8,7 @@ import frappe
from frappe.desk.form.load import get_attachments
from frappe.desk.query_report import generate_report_result
from frappe.model.document import Document
from frappe.monitor import add_data_to_monitor
from frappe.utils import gzip_compress, gzip_decompress
from frappe.utils.background_jobs import enqueue

@@ -25,6 +26,8 @@ def run_background(prepared_report):
instance = frappe.get_doc("Prepared Report", prepared_report)
report = frappe.get_doc("Report", instance.ref_report_doctype)

add_data_to_monitor(report=instance.ref_report_doctype)

try:
report.custom_columns = []



+ 4
- 2
frappe/core/doctype/rq_worker/rq_worker.py View File

@@ -23,8 +23,10 @@ class RQWorker(Document):
start = cint(args.get("start")) or 0
page_length = cint(args.get("page_length")) or 20

workers = get_workers()[start : start + page_length]
return [serialize_worker(worker) for worker in workers]
workers = get_workers()

valid_workers = [w for w in workers if w.pid][start : start + page_length]
return [serialize_worker(worker) for worker in valid_workers]

@staticmethod
def get_count(args) -> int:


+ 1
- 0
frappe/custom/doctype/customize_form/customize_form.js View File

@@ -178,6 +178,7 @@ frappe.ui.form.on("Customize Form", {
fieldname: "module",
options: "Module Def",
label: __("Module to Export"),
reqd: 1,
},
{
fieldtype: "Check",


+ 3
- 5
frappe/desk/form/load.py View File

@@ -64,11 +64,9 @@ def getdoctype(doctype, with_parent=False, cached_timestamp=None):
parent_dt = None

# with parent (called from report builder)
if with_parent:
parent_dt = frappe.model.meta.get_parent_dt(doctype)
if parent_dt:
docs = get_meta_bundle(parent_dt)
frappe.response["parent_dt"] = parent_dt
if with_parent and (parent_dt := frappe.model.meta.get_parent_dt(doctype)):
docs = get_meta_bundle(parent_dt)
frappe.response["parent_dt"] = parent_dt

if not docs:
docs = get_meta_bundle(doctype)


+ 3
- 5
frappe/desk/form/meta.py View File

@@ -35,12 +35,10 @@ ASSET_KEYS = (
def get_meta(doctype, cached=True):
# don't cache for developer mode as js files, templates may be edited
if cached and not frappe.conf.developer_mode:
meta = frappe.cache().hget("form_meta", doctype)
if meta:
meta = FormMeta(meta)
else:
meta = frappe.cache().hget("doctype_form_meta", doctype)
if not meta:
meta = FormMeta(doctype)
frappe.cache().hset("form_meta", doctype, meta.as_dict())
frappe.cache().hset("doctype_form_meta", doctype, meta)
else:
meta = FormMeta(doctype)



+ 3
- 0
frappe/desk/form/save.py View File

@@ -5,6 +5,7 @@ import json

import frappe
from frappe.desk.form.load import run_onload
from frappe.monitor import add_data_to_monitor


@frappe.whitelist()
@@ -25,6 +26,8 @@ def savedocs(doc, action):
run_onload(doc)
send_updated_docs(doc)

add_data_to_monitor(doctype=doc.doctype, action=action)

frappe.msgprint(frappe._("Saved"), indicator="green", alert=True)




+ 2
- 11
frappe/desk/page/setup_wizard/setup_wizard.py View File

@@ -2,11 +2,10 @@
# License: MIT. See LICENSE

import json
import os

import frappe
from frappe.geo.country_info import get_country_info
from frappe.translate import get_dict, send_translations, set_default_language
from frappe.translate import get_messages_for_boot, send_translations, set_default_language
from frappe.utils import cint, strip
from frappe.utils.password import update_password

@@ -290,15 +289,7 @@ def load_messages(language):
frappe.clear_cache()
set_default_language(get_language_code(language))
frappe.db.commit()
m = get_dict("page", "setup-wizard")

for path in frappe.get_hooks("setup_wizard_requires"):
# common folder `assets` served from `sites/`
js_file_path = os.path.abspath(frappe.get_site_path("..", *path.strip("/").split("/")))
m.update(get_dict("jsfile", js_file_path))

m.update(get_dict("boot"))
send_translations(m)
send_translations(get_messages_for_boot())
return frappe.local.lang




+ 3
- 0
frappe/desk/query_report.py View File

@@ -12,6 +12,7 @@ from frappe import _
from frappe.core.utils import ljust_list
from frappe.model.utils import render_include
from frappe.modules import get_module_path, scrub
from frappe.monitor import add_data_to_monitor
from frappe.permissions import get_role_permissions
from frappe.translate import send_translations
from frappe.utils import (
@@ -254,6 +255,8 @@ def run(

result["add_total_row"] = report.add_total_row and not result.get("skip_total_row", False)

add_data_to_monitor(report=report)

return result




+ 61
- 53
frappe/model/meta.py View File

@@ -40,21 +40,31 @@ from frappe.model.workflow import get_workflow_name
from frappe.modules import load_doctype_module
from frappe.utils import cast, cint, cstr

DEFAULT_FIELD_LABELS = {
"name": lambda: _("ID"),
"creation": lambda: _("Created On"),
"docstatus": lambda: _("Document Status"),
"idx": lambda: _("Index"),
"modified": lambda: _("Last Updated On"),
"modified_by": lambda: _("Last Updated By"),
"owner": lambda: _("Created By"),
"_user_tags": lambda: _("Tags"),
"_liked_by": lambda: _("Liked By"),
"_comments": lambda: _("Comments"),
"_assign": lambda: _("Assigned To"),
}


def get_meta(doctype, cached=True) -> "Meta":
if cached:
if not frappe.local.meta_cache.get(doctype):
meta = frappe.cache().hget("meta", doctype)
if meta:
meta = Meta(meta)
else:
meta = Meta(doctype)
frappe.cache().hset("meta", doctype, meta.as_dict())
frappe.local.meta_cache[doctype] = meta
if not cached:
return Meta(doctype)

return frappe.local.meta_cache[doctype]
else:
return load_meta(doctype)
if meta := frappe.cache().hget("doctype_meta", doctype):
return meta

meta = Meta(doctype)
frappe.cache().hset("doctype_meta", doctype, meta)
return meta


def load_meta(doctype):
@@ -86,7 +96,7 @@ def load_doctype_from_file(doctype):
class Meta(Document):
_metaclass = True
default_fields = list(default_fields)[1:]
special_doctypes = (
special_doctypes = {
"DocField",
"DocPerm",
"DocType",
@@ -94,24 +104,25 @@ class Meta(Document):
"DocType Action",
"DocType Link",
"DocType State",
)
}
standard_set_once_fields = [
frappe._dict(fieldname="creation", fieldtype="Datetime"),
frappe._dict(fieldname="owner", fieldtype="Data"),
]

def __init__(self, doctype):
self._fields = {}
# from cache
if isinstance(doctype, dict):
super().__init__(doctype)
self.init_field_map()
return

elif isinstance(doctype, Document):
if isinstance(doctype, Document):
super().__init__(doctype.as_dict())
self.process()

else:
super().__init__("DocType", doctype)
self.process()

self.process()

def load_from_db(self):
try:
@@ -126,10 +137,12 @@ class Meta(Document):
# don't process for special doctypes
# prevent's circular dependency
if self.name in self.special_doctypes:
self.init_field_map()
return

self.add_custom_fields()
self.apply_property_setters()
self.init_field_map()
self.sort_fields()
self.get_valid_columns()
self.set_custom_permissions()
@@ -233,36 +246,24 @@ class Meta(Document):

def get_field(self, fieldname):
"""Return docfield from meta"""
if not self._fields:
for f in self.get("fields"):
self._fields[f.fieldname] = f

return self._fields.get(fieldname)

def has_field(self, fieldname):
"""Returns True if fieldname exists"""
return True if self.get_field(fieldname) else False

return fieldname in self._fields

def get_label(self, fieldname):
"""Get label of the given fieldname"""
df = self.get_field(fieldname)
if df:
label = df.label
else:
label = {
"name": _("ID"),
"creation": _("Created On"),
"docstatus": _("Document Status"),
"idx": _("Index"),
"modified": _("Last Updated On"),
"modified_by": _("Last Updated By"),
"owner": _("Created By"),
"_user_tags": _("Tags"),
"_liked_by": _("Liked By"),
"_comments": _("Comments"),
"_assign": _("Assigned To"),
}.get(fieldname) or _("No Label")
return label

if df := self.get_field(fieldname):
return df.label

if fieldname in DEFAULT_FIELD_LABELS:
return DEFAULT_FIELD_LABELS[fieldname]()

return _("No Label")

def get_options(self, fieldname):
return self.get_field(fieldname).options
@@ -273,12 +274,9 @@ class Meta(Document):
if df.fieldtype == "Link":
return df.options

elif df.fieldtype == "Dynamic Link":
if df.fieldtype == "Dynamic Link":
return self.get_options(df.options)

else:
return None

def get_search_fields(self):
search_fields = self.search_fields or "name"
search_fields = [d.strip() for d in search_fields.split(",")]
@@ -340,8 +338,9 @@ class Meta(Document):

def is_translatable(self, fieldname):
"""Return true of false given a field"""
field = self.get_field(fieldname)
return field and field.translatable

if field := self.get_field(fieldname):
return field.translatable

def get_workflow(self):
return get_workflow_name(self.name)
@@ -349,11 +348,10 @@ class Meta(Document):
def get_naming_series_options(self) -> list[str]:
"""Get list naming series options."""

field = self.get_field("naming_series")
if field:
if field := self.get_field("naming_series"):
options = field.options or ""

return options.split("\n")

return []

def add_custom_fields(self):
@@ -450,6 +448,9 @@ class Meta(Document):

self.set(fieldname, new_list)

def init_field_map(self):
self._fields = {field.fieldname: field for field in self.fields}

def sort_fields(self):
"""sort on basis of insert_after"""
custom_fields = sorted(self.get_custom_fields(), key=lambda df: df.idx)
@@ -666,10 +667,17 @@ def is_single(doctype):


def get_parent_dt(dt):
parent_dt = frappe.get_all(
"DocField", "parent", dict(fieldtype=["in", frappe.model.table_fields], options=dt), limit=1
if not frappe.is_table(dt):
return ""

return (
frappe.db.get_value(
"DocField",
{"fieldtype": ("in", frappe.model.table_fields), "options": dt},
"parent",
)
or ""
)
return parent_dt and parent_dt[0].parent or ""


def set_fieldname(field_id, fieldname):


+ 12
- 1
frappe/monitor.py View File

@@ -25,6 +25,13 @@ def stop(response=None):
frappe.local.monitor.dump(response)


def add_data_to_monitor(**kwargs) -> None:
"""Add additional custom key-value pairs along with monitor log.
Note: Key-value pairs should be simple JSON exportable types."""
if hasattr(frappe.local, "monitor"):
frappe.local.monitor.add_custom_data(**kwargs)


def log_file():
return os.path.join(frappe.utils.get_bench_path(), "logs", "monitor.json.log")

@@ -71,6 +78,10 @@ class Monitor:
waitdiff = self.data.timestamp - job.enqueued_at
self.data.job.wait = int(waitdiff.total_seconds() * 1000000)

def add_custom_data(self, **kwargs):
if self.data:
self.data.update(kwargs)

def dump(self, response=None):
try:
timediff = datetime.utcnow() - self.data.timestamp
@@ -94,7 +105,7 @@ class Monitor:
def store(self):
if frappe.cache().llen(MONITOR_REDIS_KEY) > MONITOR_MAX_ENTRIES:
frappe.cache().ltrim(MONITOR_REDIS_KEY, 1, -1)
serialized = json.dumps(self.data, sort_keys=True, default=str)
serialized = json.dumps(self.data, sort_keys=True, default=str, separators=(",", ":"))
frappe.cache().rpush(MONITOR_REDIS_KEY, serialized)




+ 12
- 7
frappe/public/js/frappe/utils/dashboard_utils.js View File

@@ -15,7 +15,7 @@ frappe.dashboard_utils = {
let chart_filter_html = `<div class="${button_class} ${filter_class} btn-group dropdown pull-right">
<a data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<button class="btn btn-secondary btn-xs">
${icon_html}
${icon_html}
<span class="filter-label">${__(filter.label)}</span>
${frappe.utils.icon("select", "xs")}
</button>
@@ -26,16 +26,21 @@ frappe.dashboard_utils = {
options_html = filter.options
.map(
(option, i) =>
// TODO: Make option translatable - be careful, since the text of the a tag is later used to perform some action
`<li>
<a class="dropdown-item" data-fieldname="${filter.fieldnames[i]}">${option}</a>
<a class="dropdown-item" data-fieldname="${
filter.fieldnames[i]
}" data-option="${encodeURIComponent(option)}">${__(option)}</a>
</li>`
)
.join("");
} else {
// TODO: Make option translatable - be careful, since the text of the a tag is later used to perform some action
options_html = filter.options
.map((option) => `<li><a class="dropdown-item">${option}</a></li>`)
.map(
(option) =>
`<li><a class="dropdown-item" data-option="${encodeURIComponent(
option
)}">${__(option)}</a></li>`
)
.join("");
}

@@ -54,8 +59,8 @@ frappe.dashboard_utils = {
fieldname = $el.attr("data-fieldname");
}

let selected_item = $el.text();
$el.parents(`.${button_class}`).find(".filter-label").text(selected_item);
let selected_item = decodeURIComponent($el.data("option"));
$el.parents(`.${button_class}`).find(".filter-label").html(__(selected_item));
filter.action(selected_item, fieldname);
});
});


+ 2
- 2
frappe/public/js/frappe/widgets/onboarding_widget.js View File

@@ -117,13 +117,13 @@ export default class OnboardingWidget extends Widget {
const set_description = () => {
let content = step.description
? frappe.markdown(step.description)
: `<h1>${step.title}</h1>`;
: `<h1>${__(step.title)}</h1>`;

if (step.action === "Create Entry") {
// add a secondary action to view list
content += `<p>
<a href='/app/${frappe.router.slug(step.reference_document)}'>
${__("Show {0} List", [step.reference_document])}</a>
${__("Show {0} List", [__(step.reference_document)])}</a>
</p>`;
}



+ 4
- 0
frappe/query_builder/functions.py View File

@@ -1,3 +1,4 @@
from datetime import time
from enum import Enum

from pypika.functions import *
@@ -35,6 +36,9 @@ Match = ImportMapper({db_type_is.MARIADB: MATCH, db_type_is.POSTGRES: TO_TSVECTO

class _PostgresTimestamp(ArithmeticExpression):
def __init__(self, datepart, timepart, alias=None):
"""Postgres would need both datepart and timepart to be a string for concatenation"""
if isinstance(timepart, time) or isinstance(datepart, time):
timepart, datepart = str(timepart), str(datepart)
if isinstance(datepart, str):
datepart = Cast(datepart, "date")
if isinstance(timepart, str):


+ 6
- 3
frappe/query_builder/terms.py View File

@@ -1,11 +1,11 @@
from datetime import timedelta
from datetime import time, timedelta
from typing import Any

from pypika.queries import QueryBuilder
from pypika.terms import Criterion, Function, ValueWrapper
from pypika.utils import format_alias_sql

from frappe.utils.data import format_timedelta
from frappe.utils.data import format_time, format_timedelta


class NamedParameterWrapper:
@@ -55,9 +55,12 @@ class ParameterizedValueWrapper(ValueWrapper):
value_sql = self.get_value_sql(quote_char=quote_char, **kwargs)
sql = param_wrapper.get_sql(param_value=value_sql, **kwargs)
else:
# * BUG: pypika doesen't parse timedeltas
# * BUG: pypika doesen't parse timedeltas and datetime.time
if isinstance(self.value, timedelta):
self.value = format_timedelta(self.value)
elif isinstance(self.value, time):
self.value = format_time(self.value)

sql = self.get_value_sql(
quote_char=quote_char,
secondary_quote_char=secondary_quote_char,


+ 8
- 0
frappe/tests/test_commands.py View File

@@ -413,6 +413,14 @@ class TestCommands(BaseTestCommands):
self.execute("bench --site {site} set-admin-password test2")
self.assertEqual(self.returncode, 0)
self.assertEqual(check_password("Administrator", "test2"), "Administrator")
frappe.db.commit()

# Reset it back to original password
original_password = frappe.conf.admin_password or "admin"
self.execute("bench --site {site} set-admin-password %s" % original_password)
self.assertEqual(self.returncode, 0)
self.assertEqual(check_password("Administrator", original_password), "Administrator")
frappe.db.commit()

@skipIf(
not (


+ 39
- 0
frappe/tests/test_perf.py View File

@@ -16,14 +16,21 @@ query. This test can be written like this.
>>> get_controller("User")

"""
import time
import unittest

from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_fixed

import frappe
from frappe.frappeclient import FrappeClient
from frappe.model.base_document import get_controller
from frappe.query_builder.utils import db_type_is
from frappe.tests.test_query_builder import run_only_if
from frappe.tests.utils import FrappeTestCase
from frappe.website.path_resolver import PathResolver


@run_only_if(db_type_is.MARIADB)
class TestPerformance(FrappeTestCase):
def reset_request_specific_caches(self):
# To simulate close to request level of handling
@@ -33,6 +40,8 @@ class TestPerformance(FrappeTestCase):
frappe.clear_cache()

def setUp(self) -> None:
self.HOST = frappe.utils.get_site_url(frappe.local.site)

self.reset_request_specific_caches()

def test_meta_caching(self):
@@ -55,6 +64,36 @@ class TestPerformance(FrappeTestCase):
with self.assertQueryCount(0):
doc.get_invalid_links()

@retry(
retry=retry_if_exception_type(AssertionError),
stop=stop_after_attempt(3),
wait=wait_fixed(0.5),
reraise=True,
)
def test_req_per_seconds_basic(self):
"""Ideally should be ran against gunicorn worker, though I have not seen any difference
when using werkzeug's run_simple for synchronous requests."""

EXPECTED_RPS = 55 # measured on GHA
FAILURE_THREASHOLD = 0.1

req_count = 1000
client = FrappeClient(self.HOST, "Administrator", self.ADMIN_PASSWORD)

start = time.perf_counter()
for _ in range(req_count):
client.get_list("ToDo", limit_page_length=1)
end = time.perf_counter()

rps = req_count / (end - start)

print(f"Completed {req_count} in {end - start} @ {rps} requests per seconds")
self.assertGreaterEqual(
rps,
EXPECTED_RPS * (1 - FAILURE_THREASHOLD),
f"Possible performance regression in basic /api/Resource list requests",
)

@unittest.skip("Not implemented")
def test_homepage_resolver(self):
paths = ["/", "/app"]


+ 43
- 0
frappe/tests/test_query_builder.py View File

@@ -1,5 +1,6 @@
import unittest
from collections.abc import Callable
from datetime import time

import frappe
from frappe.query_builder import Case
@@ -74,6 +75,26 @@ class TestCustomFunctionsMariaDB(FrappeTestCase):
str(select_query).lower(),
)

def test_time(self):
note = frappe.qb.DocType("Note")
self.assertEqual(
"TIMESTAMP('2021-01-01','00:00:21')", CombineDatetime("2021-01-01", time(0, 0, 21)).get_sql()
)

select_query = frappe.qb.from_(note).select(
CombineDatetime(note.posting_date, note.posting_time)
)
self.assertIn("select timestamp(`posting_date`,`posting_time`)", str(select_query).lower())

select_query = select_query.where(
CombineDatetime(note.posting_date, note.posting_time)
>= CombineDatetime("2021-01-01", time(0, 0, 1))
)
self.assertIn(
"timestamp(`posting_date`,`posting_time`)>=timestamp('2021-01-01','00:00:01')",
str(select_query).lower(),
)

def test_cast(self):
note = frappe.qb.DocType("Note")
self.assertEqual("CONCAT(name,'')", Cast_(note.name, "varchar").get_sql())
@@ -141,6 +162,28 @@ class TestCustomFunctionsPostgres(FrappeTestCase):
'"tabnote"."posting_date"+"tabnote"."posting_time" "timestamp"', str(select_query).lower()
)

def test_time(self):
note = frappe.qb.DocType("Note")

self.assertEqual(
"CAST('2021-01-01' AS DATE)+CAST('00:00:21' AS TIME)",
CombineDatetime("2021-01-01", time(0, 0, 21)).get_sql(),
)

select_query = frappe.qb.from_(note).select(
CombineDatetime(note.posting_date, note.posting_time)
)
self.assertIn('select "posting_date"+"posting_time"', str(select_query).lower())

select_query = select_query.where(
CombineDatetime(note.posting_date, note.posting_time)
>= CombineDatetime("2021-01-01", time(0, 0, 1))
)
self.assertIn(
"""where "posting_date"+"posting_time">=cast('2021-01-01' as date)+cast('00:00:01' as time)""",
str(select_query).lower(),
)

def test_cast(self):
note = frappe.qb.DocType("Note")
self.assertEqual("CAST(name AS VARCHAR)", Cast_(note.name, "varchar").get_sql())


+ 1
- 1
frappe/tests/utils.py View File

@@ -26,9 +26,9 @@ class FrappeTestCase(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.TEST_SITE = getattr(frappe.local, "site", None) or cls.TEST_SITE
cls.ADMIN_PASSWORD = frappe.get_conf(cls.TEST_SITE).admin_password
# flush changes done so far to avoid flake
frappe.db.commit()
frappe.db.begin()
if cls.SHOW_TRANSACTION_COMMIT_WARNINGS:
frappe.db.add_before_commit(_commit_watcher)



+ 1
- 1
frappe/utils/__init__.py View File

@@ -459,7 +459,7 @@ def get_site_base_path():


def get_site_path(*path):
return get_path(base=get_site_base_path(), *path)
return get_path(*path, base=get_site_base_path())


def get_files_path(*path, **kwargs):


+ 6
- 1
frappe/utils/data.py View File

@@ -45,6 +45,7 @@ URL_NOTATION_PATTERN = re.compile(
) # background-image: url('/assets/...')
DURATION_PATTERN = re.compile(r"^(?:(\d+d)?((^|\s)\d+h)?((^|\s)\d+m)?((^|\s)\d+s)?)$")
HTML_TAG_PATTERN = re.compile("<[^>]+>")
MARIADB_SPECIFIC_COMMENT = re.compile(r"#.*")


class Weekday(Enum):
@@ -1809,8 +1810,11 @@ def sanitize_column(column_name: str) -> None:

from frappe import _

regex = re.compile("^.*[,'();].*")
column_name = sqlparse.format(column_name, strip_comments=True, keyword_case="lower")
if frappe.db and frappe.db.db_type == "mariadb":
# strip mariadb specific comments which are like python single line comments
column_name = MARIADB_SPECIFIC_COMMENT.sub("", column_name)

blacklisted_keywords = [
"select",
"create",
@@ -1826,6 +1830,7 @@ def sanitize_column(column_name: str) -> None:
def _raise_exception():
frappe.throw(_("Invalid field name {0}").format(column_name), frappe.DataError)

regex = re.compile("^.*[,'();].*")
if "ifnull" in column_name:
if regex.match(column_name):
# to avoid and, or


Loading…
Cancel
Save