From afdb546e4448e8aab170f9221fe1e4ee3aea2cd1 Mon Sep 17 00:00:00 2001 From: Mathieu Duckerts-Antoine Date: Wed, 19 Jul 2023 08:01:27 +0000 Subject: [PATCH] [FIX] web: domain field: quick save after debug edit When a domain field value is edited via the debug textarea, no search_count is done for performance reasons. A single exception is done when saving the record. Then we check the validity of the domain created in the debug textarea in order to avoid to save an invalid domain in db (and get tracebacks,..). The problem is that a search_count can take a very long time to be executed if the domain is valid. Here we introduce a route /web/domain/validate in order to quickly check the validity of a domain and use it in domain field in order to fix the above mentionned performance issue. Note that the search_count is still done if it useful but does not have to be waited anymore. X-original-commit: 40288221c39ff8fba41cd6ac231ab33600dc0cd4 Part-of: odoo/odoo#128913 Co-authored-by: Oliver Dony Co-authored-by: Mathieu Duckerts-Antoine --- addons/web/controllers/__init__.py | 1 + addons/web/controllers/domain.py | 34 ++++++++++++++ .../src/views/fields/domain/domain_field.js | 21 ++++++++- .../tests/views/fields/domain_field_tests.js | 47 +++++++++++++++++++ addons/web/tests/__init__.py | 1 + addons/web/tests/test_domain.py | 42 +++++++++++++++++ 6 files changed, 144 insertions(+), 2 deletions(-) create mode 100644 addons/web/controllers/domain.py create mode 100644 addons/web/tests/test_domain.py diff --git a/addons/web/controllers/__init__.py b/addons/web/controllers/__init__.py index dd76bbf7057..ceb32b0072c 100644 --- a/addons/web/controllers/__init__.py +++ b/addons/web/controllers/__init__.py @@ -4,6 +4,7 @@ from . import action from . import binary from . import database from . import dataset +from . import domain from . import export from . import home from . import pivot diff --git a/addons/web/controllers/domain.py b/addons/web/controllers/domain.py new file mode 100644 index 00000000000..50890895c59 --- /dev/null +++ b/addons/web/controllers/domain.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import http, _ +from odoo.http import Controller, request +from odoo.exceptions import ValidationError +from odoo.tools.misc import mute_logger + +class Domain(Controller): + + @http.route('/web/domain/validate', type='json', auth="user") + def validate(self, model, domain): + """ Parse `domain` and verify that it can be used to search on `model` + :return: True when the domain is valid, otherwise False + :raises ValidationError: if `model` is invalid + """ + Model = request.env.get(model) + if Model is None: + raise ValidationError(_('Invalid model: %s', model)) + try: + # go through the motions of preparing the final SQL for the domain, + # so that anything invalid will raise an exception. + query = Model.sudo()._search(domain) + sql, params = query.select() + + # Execute the search in EXPLAIN mode, to have the query parser + # verify it. EXPLAIN will make sure the query is never actually executed + # An alternative to EXPLAIN would be a LIMIT 0 clause, but the semantics + # of a falsy `limit` parameter when calling _search() do not permit it. + with mute_logger('odoo.sql_db'): + request.env.cr.execute(f"EXPLAIN {sql}", params) + return True + except Exception: # pylint: disable=broad-except + return False diff --git a/addons/web/static/src/views/fields/domain/domain_field.js b/addons/web/static/src/views/fields/domain/domain_field.js index 6a710ae3cfc..4456fc3d890 100644 --- a/addons/web/static/src/views/fields/domain/domain_field.js +++ b/addons/web/static/src/views/fields/domain/domain_field.js @@ -27,6 +27,7 @@ export class DomainField extends Component { }; setup() { + this.rpc = useService("rpc"); this.orm = useService("orm"); this.addDialog = useOwnedDialogs(); @@ -50,8 +51,12 @@ export class DomainField extends Component { if (this.isDebugEdited) { const props = this.props; ev.detail.proms.push( - this.checkProps(props).then(() => { - if (!this.state.isValid) { + this.quickValidityCheck(props).then((isValid) => { + if (isValid) { + this.isDebugEdited = false; // will allow the count to be loaded if needed + } else { + this.state.isValid = false; + this.state.recordCount = 0; props.record.setInvalidField(props.name); } }) @@ -157,6 +162,18 @@ export class DomainField extends Component { }); } + async quickValidityCheck(props) { + const resModel = this.getResModel(props); + if (!resModel) { + return false; + } + const domain = this.getEvaluatedDomain(props); + if (domain.isInvalid) { + return false; + } + return this.rpc("/web/domain/validate", { model: resModel, domain }); + } + update(domain, isDebugEdited = false) { this.isDebugEdited = isDebugEdited; return this.props.record.update({ [this.props.name]: domain }); diff --git a/addons/web/static/tests/views/fields/domain_field_tests.js b/addons/web/static/tests/views/fields/domain_field_tests.js index b81c8be99c3..444707b2f1f 100644 --- a/addons/web/static/tests/views/fields/domain_field_tests.js +++ b/addons/web/static/tests/views/fields/domain_field_tests.js @@ -497,6 +497,9 @@ QUnit.module("Fields", (hooks) => { if (method === "search_count") { assert.step(JSON.stringify(args[0])); } + if (route === "/web/domain/validate") { + return true; + } }, }); @@ -562,6 +565,9 @@ QUnit.module("Fields", (hooks) => { if (method === "write") { throw new Error("should not save"); } + if (route === "/web/domain/validate") { + return false; + } }, }); @@ -735,6 +741,9 @@ QUnit.module("Fields", (hooks) => { if (method === "write") { assert.strictEqual(args[1].foo, rawDomain); } + if (route === "/web/domain/validate") { + return true; + } }, }); @@ -942,4 +951,42 @@ QUnit.module("Fields", (hooks) => { await click(target, ".modal-footer .btn-primary"); assert.containsOnce(target, ".modal", "the domain is invalid: the dialog is not closed"); }); + + QUnit.test( + "quick check on save if domain has been edited via the debug input", + async function (assert) { + patchWithCleanup(odoo, { debug: true }); + serverData.models.partner.fields.display_name.default = "[['id', '=', False]]"; + await makeView({ + type: "form", + resModel: "partner", + serverData, + arch: ` +
+ + `, + mockRPC: (route, args) => { + if (route === "/web/domain/validate") { + assert.step(route); + assert.deepEqual(args, { + domain: [["id", "!=", false]], + model: "partner", + }); + return true; + } + }, + }); + assert.strictEqual( + target.querySelector(".o_domain_show_selection_button").textContent.trim(), + "0 record(s)" + ); + await editInput(target, ".o_domain_debug_input", "[['id', '!=', False]]"); + await click(target, "button.o_form_button_save"); + assert.verifySteps(["/web/domain/validate"]); + assert.strictEqual( + target.querySelector(".o_domain_show_selection_button").textContent.trim(), + "6 record(s)" + ); + } + ); }); diff --git a/addons/web/tests/__init__.py b/addons/web/tests/__init__.py index 2c19c17978c..7420ed38091 100644 --- a/addons/web/tests/__init__.py +++ b/addons/web/tests/__init__.py @@ -16,5 +16,6 @@ from . import test_assets from . import test_assets_xml from . import test_login from . import test_web_search_read +from . import test_domain from . import test_web_redirect from . import test_res_users diff --git a/addons/web/tests/test_domain.py b/addons/web/tests/test_domain.py new file mode 100644 index 00000000000..34b8dddcb55 --- /dev/null +++ b/addons/web/tests/test_domain.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import json + +from odoo.tests import HttpCase, tagged +from odoo.tools import mute_logger + +@tagged('post_install', '-at_install') +class DomainTest(HttpCase): + + def test_domain_validate(self): + self.authenticate("demo", "demo") + + with mute_logger('odoo.http'): + resp = self.url_open( + '/web/domain/validate', + headers={'Content-Type': 'application/json'}, + data=json.dumps({'params': {'model':'i', 'domain':[]}}), + ) + self.assertEqual(resp.json()['error']['data']['message'], "Invalid model: i") + + resp = self.url_open( + '/web/domain/validate', + headers={'Content-Type': 'application/json'}, + data=json.dumps({'params': {'model':'res.users', 'domain':[]}}), + ) + self.assertEqual(resp.json()['result'], True) + + resp = self.url_open( + '/web/domain/validate', + headers={'Content-Type': 'application/json'}, + data=json.dumps({'params': {'model':'res.users', 'domain':[('name', 'ilike', 'ad')]}}), + ) + self.assertEqual(resp.json()['result'], True) + + resp = self.url_open( + '/web/domain/validate', + headers={'Content-Type': 'application/json'}, + data=json.dumps({'params': {'model':'res.users', 'domain':[('hop')]}}), + ) + self.assertEqual(resp.json()['result'], False)