diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index abf0c8afad..fe19ffe68e 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -101,9 +101,15 @@ runs: # Install System Dependencies start_time=$(date +%s) curl -LsS https://r.mariadb.com/downloads/mariadb_repo_setup | sudo bash + + sudo sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list' + wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add - + sudo apt -qq update sudo apt -qq remove mysql-server mysql-client - sudo apt -qq install libcups2-dev redis-server mariadb-client libmariadb-dev + sudo apt -qq remove -y postgresql-client postgresql-client-16 postgresql-client-common + sudo apt -qq install libcups2-dev redis-server mariadb-client libmariadb-dev postgresql-client-18 libpq-dev + echo "/usr/lib/postgresql/18/bin" >> $GITHUB_PATH wget -q -O /tmp/wkhtmltox.deb https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-2/wkhtmltox_0.12.6.1-2.jammy_amd64.deb sudo apt install /tmp/wkhtmltox.deb @@ -169,6 +175,14 @@ runs: mariadb --host 127.0.0.1 --port 3306 -u root -p${{ inputs.db-root-password }} -e "FLUSH PRIVILEGES"; fi + if [ "$DB" == "postgres" ]; then + export PGPASSWORD='travis' + psql -h 127.0.0.1 -p 5432 -c "CREATE DATABASE test_frappe" -U postgres + psql -h 127.0.0.1 -p 5432 -c "CREATE USER test_frappe WITH PASSWORD 'test_frappe'" -U postgres + psql -h 127.0.0.1 -p 5432 -U postgres -c "GRANT ALL PRIVILEGES ON DATABASE test_frappe TO test_frappe;" + unset PGPASSWORD + fi + - shell: bash -e {0} run: | # Install App(s) diff --git a/.github/helper/db/postgres.json b/.github/helper/db/postgres.json new file mode 100644 index 0000000000..73a22b6d49 --- /dev/null +++ b/.github/helper/db/postgres.json @@ -0,0 +1,18 @@ +{ + "db_host": "127.0.0.1", + "db_port": 5432, + "db_name": "test_frappe", + "db_password": "test_frappe", + "db_type": "postgres", + "allow_tests": true, + "auto_email_id": "test@example.com", + "mail_server": "localhost", + "mail_port": 2525, + "mail_login": "test@example.com", + "mail_password": "test", + "admin_password": "admin", + "root_login": "postgres", + "root_password": "travis", + "host_name": "http://test_site:8000", + "server_script_enabled": true +} \ No newline at end of file diff --git a/.github/workflows/_base-server-tests.yml b/.github/workflows/_base-server-tests.yml index f7f744a935..d76a380c41 100644 --- a/.github/workflows/_base-server-tests.yml +++ b/.github/workflows/_base-server-tests.yml @@ -22,6 +22,10 @@ on: required: false type: boolean default: false + enable-postgres: + required: false + type: boolean + default: true enable-coverage: required: false type: boolean @@ -62,9 +66,20 @@ jobs: strategy: fail-fast: false matrix: - db: ${{ fromJson(inputs.enable-sqlite && '["mariadb", "sqlite"]' || '["mariadb"]') }} + db: ${{ fromJson(inputs.enable-sqlite && (inputs.enable-postgres && '["mariadb", "postgres", "sqlite"]' || '["mariadb", "sqlite"]') || (inputs.enable-postgres && '["mariadb", "postgres"]' || '["mariadb"]')) }} index: ${{ fromJson(needs.gen-idx-integration.outputs.indices) }} services: + postgres: + image: postgres:18.0 + ports: + - 5432:5432 + env: + POSTGRES_PASSWORD: travis + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 3 mariadb: image: mariadb:11.8 ports: diff --git a/.github/workflows/server-tests.yml b/.github/workflows/server-tests.yml index 3bcb763984..311616e4ee 100644 --- a/.github/workflows/server-tests.yml +++ b/.github/workflows/server-tests.yml @@ -44,6 +44,7 @@ jobs: name: Tests uses: ./.github/workflows/_base-server-tests.yml with: + enable-postgres: ${{ contains(github.event.pull_request.labels.*.name, 'postgres') }} # This enables PostgreSQL to run tests enable-sqlite: false # This will test against both MariaDB and SQLite if enabled parallel-runs: 2 enable-coverage: ${{ github.event_name != 'pull_request' }} diff --git a/frappe/commands/test_commands.py b/frappe/commands/test_commands.py index cde28456ca..ad00bb0933 100644 --- a/frappe/commands/test_commands.py +++ b/frappe/commands/test_commands.py @@ -469,11 +469,10 @@ class TestCommands(BaseTestCommands): self.assertEqual(check_password("Administrator", original_password), "Administrator") @skipIf( - not (frappe.conf.root_password and frappe.conf.admin_password and frappe.conf.db_type == "mariadb"), + not (frappe.conf.root_password and frappe.conf.admin_password and frappe.conf.db_type != "sqlite"), "DB Root password and Admin password not set in config", ) def test_bench_drop_site_should_archive_site(self): - # TODO: Make this test postgres compatible site = TEST_SITE self.execute( @@ -499,7 +498,7 @@ class TestCommands(BaseTestCommands): self.assertTrue(os.path.exists(archive_directory)) @skipIf( - not (frappe.conf.root_password and frappe.conf.admin_password and frappe.conf.db_type == "mariadb"), + not (frappe.conf.root_password and frappe.conf.admin_password and frappe.conf.db_type != "sqlite"), "DB Root password and Admin password not set in config", ) def test_force_install_app(self): @@ -656,10 +655,10 @@ class TestBackups(BaseTestCommands): except OSError: pass - @run_only_if(db_type_is.MARIADB) def test_backup_no_options(self): """Take a backup without any options""" before_backup = fetch_latest_backups(partial=True) + time.sleep(1) self.execute("bench --site {site} backup") after_backup = fetch_latest_backups(partial=True) @@ -1003,9 +1002,11 @@ class TestDBCli(BaseTestCommands): self.execute("bench --site {site} db-console", kwargs={"cmd_input": cmd_input}) self.assertEqual(self.returncode, 0) - @run_only_if(db_type_is.MARIADB) def test_db_cli_with_sql(self): - self.execute("bench --site {site} db-console -e 'select 1'") + if frappe.db.db_type == "postgres": + self.execute("bench --site {site} db-console -c 'select 1'") + elif frappe.db.db_type == "mariadb": + self.execute("bench --site {site} db-console -e 'select 1'") self.assertEqual(self.returncode, 0) self.assertIn("1", self.stdout) diff --git a/frappe/core/doctype/doctype/test_doctype.py b/frappe/core/doctype/doctype/test_doctype.py index 20820fbaa6..5eeb91a790 100644 --- a/frappe/core/doctype/doctype/test_doctype.py +++ b/frappe/core/doctype/doctype/test_doctype.py @@ -840,7 +840,20 @@ class TestDocType(IntegrationTestCase): ], ).insert(ignore_if_duplicate=True) decimal_field_type = frappe.db.get_column_type(doctype.name, "decimal_field") - self.assertIn("(30,3)", decimal_field_type.lower()) + if frappe.db.db_type == "postgres": + result = frappe.db.sql( + """ + SELECT numeric_precision, numeric_scale + FROM information_schema.columns + WHERE lower(table_name) = lower(%s) + AND column_name = %s + """, + (f"tab{doctype.name}", "decimal_field"), + ) + length, precision = result[0] + self.assertEqual((length, precision), (30, 3)) + elif frappe.db.db_type == "mariadb": + self.assertIn("(30,3)", decimal_field_type.lower()) def test_decimal_field_precision_exceeds_length(self): doctype = new_doctype( diff --git a/frappe/core/doctype/user_invitation/test_user_invitation.py b/frappe/core/doctype/user_invitation/test_user_invitation.py index e3cab914c0..634c739d8e 100644 --- a/frappe/core/doctype/user_invitation/test_user_invitation.py +++ b/frappe/core/doctype/user_invitation/test_user_invitation.py @@ -55,15 +55,18 @@ class IntegrationTestUserInvitation(IntegrationTestCase): @classmethod def delete_all_user_roles(cls): - frappe.db.sql("DELETE FROM `tabUser Role`") + query = "DELETE FROM `tabUser Role`" + frappe.db.sql(cls.normalize_sql(query)) @classmethod def delete_all_invitations(cls): - frappe.db.sql("DELETE FROM `tabUser Invitation`") + query = "DELETE FROM `tabUser Invitation`" + frappe.db.sql(cls.normalize_sql(query)) @classmethod def delete_invitation(cls, name: str): - frappe.db.sql(f'DELETE FROM `tabUser Invitation` WHERE name = "{name}"') + query = "DELETE FROM `tabUser Invitation` WHERE name = %s" + frappe.db.sql(cls.normalize_sql(query), name) def setUp(self): super().setUp() diff --git a/frappe/desk/reportview.py b/frappe/desk/reportview.py index c56bcdadb1..97fa71b271 100644 --- a/frappe/desk/reportview.py +++ b/frappe/desk/reportview.py @@ -63,6 +63,7 @@ def get_count() -> int | None: distinct = "distinct " if args.distinct else "" args.limit = cint(args.limit) fieldname = f"{distinct}`tab{args.doctype}`.name" + args.pop("distinct") # to avoid a double DISTINCT concat in db_query args.order_by = None # args.limit is specified to avoid getting accurate count. diff --git a/frappe/email/test_smtp.py b/frappe/email/test_smtp.py index dc5faccdd2..01c55372e1 100644 --- a/frappe/email/test_smtp.py +++ b/frappe/email/test_smtp.py @@ -26,7 +26,8 @@ class TestSMTP(IntegrationTestCase): # remove mail_server config so that test@example.com is not created mail_server = frappe.conf.get("mail_server") - del frappe.conf["mail_server"] + if "mail_server" in frappe.conf: + del frappe.conf["mail_server"] frappe.local.outgoing_email_account = {} @@ -47,9 +48,9 @@ class TestSMTP(IntegrationTestCase): password="password", enable_outgoing=1, default_outgoing=1, - append_to="Todo", + append_to="ToDo", ) - self.assertEqual(EmailAccount.find_outgoing(match_by_doctype="Todo").email_id, "append_to@gmail.com") + self.assertEqual(EmailAccount.find_outgoing(match_by_doctype="ToDo").email_id, "append_to@gmail.com") # add back the mail_server frappe.conf["mail_server"] = mail_server diff --git a/frappe/tests/classes/integration_test_case.py b/frappe/tests/classes/integration_test_case.py index 07e82a8992..ac3809b137 100644 --- a/frappe/tests/classes/integration_test_case.py +++ b/frappe/tests/classes/integration_test_case.py @@ -118,7 +118,7 @@ class IntegrationTestCase(UnitTestCase): def _sql_with_count(*args, **kwargs): ret = orig_sql(*args, **kwargs) - queries.append(args[0].last_query) + queries.append(str(args[0].last_query)) return ret try: diff --git a/frappe/tests/classes/unit_test_case.py b/frappe/tests/classes/unit_test_case.py index 45b8e56963..65aa4e616d 100644 --- a/frappe/tests/classes/unit_test_case.py +++ b/frappe/tests/classes/unit_test_case.py @@ -105,6 +105,8 @@ class UnitTestCase(unittest.TestCase, BaseTestCase): """Formats SQL consistently so simple string comparisons can work on them.""" import sqlparse + if frappe.db.db_type == "postgres": + query = query.replace("`", '"') return sqlparse.format(query.strip(), keyword_case="upper", reindent=True, strip_comments=True) diff --git a/frappe/tests/test_db.py b/frappe/tests/test_db.py index 2dc6f2cfe6..286c492140 100644 --- a/frappe/tests/test_db.py +++ b/frappe/tests/test_db.py @@ -1050,14 +1050,13 @@ class TestDDLCommandsPost(IntegrationTestCase): def test_is(self): user = frappe.qb.DocType("User") - self.assertIn( - 'coalesce("name",', - frappe.db.get_values(user, filters={user.name: ("is", "set")}, run=False).lower(), - ) - self.assertIn( - 'coalesce("name",', - frappe.db.get_values(user, filters={user.name: ("is", "not set")}, run=False).lower(), - ) + query_is_set = frappe.db.get_values(user, filters={user.name: ("is", "set")}, run=False).lower() + + query_is_not_set = frappe.db.get_values( + user, filters={user.name: ("is", "not set")}, run=False + ).lower() + self.assertIn('"name"<>%(param1)s', query_is_set) + self.assertIn('"name" is null or "name"=%(param1)s', query_is_not_set) @run_only_if(db_type_is.POSTGRES) diff --git a/frappe/tests/test_db_query.py b/frappe/tests/test_db_query.py index 3f196d7214..03ef9bfb65 100644 --- a/frappe/tests/test_db_query.py +++ b/frappe/tests/test_db_query.py @@ -221,8 +221,8 @@ class TestDBQuery(IntegrationTestCase): # get as conditions if frappe.db.db_type == "mariadb": assertion_string = """(((ifnull(`tabTest Blog Post`.`name`, '')='' or `tabTest Blog Post`.`name` in ('_Test Blog Post 1', '_Test Blog Post'))))""" - else: - assertion_string = """(((ifnull(cast(`tabBlog Post`.`name` as varchar), '')='' or cast(`tabBlog Post`.`name` as varchar) in ('_Test Blog Post 1', '_Test Blog Post'))))""" + elif frappe.db.db_type == "postgres": + assertion_string = """(((ifnull(cast(`tabTest Blog Post`.`name` as varchar), '')='' or cast(`tabTest Blog Post`.`name` as varchar) in ('_Test Blog Post 1', '_Test Blog Post'))))""" self.assertEqual(build_match_conditions(as_condition=True), assertion_string) @@ -1244,7 +1244,6 @@ class TestDBQuery(IntegrationTestCase): class TestReportView(IntegrationTestCase): - @run_only_if(db_type_is.MARIADB) # TODO: postgres name casting is messed up def test_get_count(self): frappe.local.request = frappe._dict() frappe.local.request.method = "GET" @@ -1282,7 +1281,7 @@ class TestReportView(IntegrationTestCase): child_filter_response = execute_cmd("frappe.desk.reportview.get_count") current_value = frappe.db.sql( # the below query is equivalent to the one in reportview.get_count - "select distinct count(distinct `tabDocType`.name) as total_count" + "select count(distinct `tabDocType`.name) as total_count" " from `tabDocType` left join `tabDocField`" " on (`tabDocField`.parenttype = 'DocType' and `tabDocField`.parent = `tabDocType`.name)" " where `tabDocField`.`fieldtype` = 'Data'" diff --git a/frappe/tests/test_db_update.py b/frappe/tests/test_db_update.py index ec2cb423b7..a6e483e29e 100644 --- a/frappe/tests/test_db_update.py +++ b/frappe/tests/test_db_update.py @@ -93,10 +93,29 @@ class TestDBUpdate(IntegrationTestCase): self.assertEqual(email_sig_column.index, 1) def check_unique_indexes(self, doctype: str, field: str): - indexes = frappe.db.sql( - f"""show index from `tab{doctype}` where column_name = '{field}' and Non_unique = 0""", - as_dict=1, - ) + if frappe.db.db_type == "postgres": + """Check if the column has a unique index (PostgreSQL equivalent of "SHOW INDEX ... WHERE Non_unique = 0")""" + indexes = frappe.db.sql( + """ + SELECT i.relname AS index_name, a.attname AS column_name + FROM + pg_class t + JOIN pg_index ix ON t.oid = ix.indrelid + JOIN pg_class i ON i.oid = ix.indexrelid + JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(ix.indkey) + WHERE + t.relname = %s + AND a.attname = %s + AND ix.indisunique = true + """, + (f"tab{doctype}", field), + as_dict=1, + ) + elif frappe.db.db_type == "mariadb": + indexes = frappe.db.sql( + f"""show index from `tab{doctype}` where column_name = '{field}' and Non_unique = 0""", + as_dict=1, + ) self.assertEqual( len(indexes), 1, msg=f"There should be 1 index on {doctype}.{field}, found {indexes}" ) @@ -111,7 +130,6 @@ class TestDBUpdate(IntegrationTestCase): doctype.save() frappe.get_doc(doctype=doctype.name, int_field=2**62 - 1).insert() - @run_only_if(db_type_is.MARIADB) def test_unique_index_on_install(self): """Only one unique index should be added""" for dt in frappe.get_all("DocType", {"is_virtual": 0, "issingle": 0}, pluck="name"): @@ -126,30 +144,31 @@ class TestDBUpdate(IntegrationTestCase): """Only one unique index should be added""" doctype = new_doctype(unique=1).insert() - field = "some_fieldname" + try: + field = "some_fieldname" - self.check_unique_indexes(doctype.name, field) - doctype.fields[0].length = 142 - doctype.save() - self.check_unique_indexes(doctype.name, field) + self.check_unique_indexes(doctype.name, field) + doctype.fields[0].length = 142 + doctype.save() + self.check_unique_indexes(doctype.name, field) - doctype.fields[0].unique = 0 - doctype.save() + doctype.fields[0].unique = 0 + doctype.save() - doctype.fields[0].unique = 1 - doctype.save() - self.check_unique_indexes(doctype.name, field) + doctype.fields[0].unique = 1 + doctype.save() + self.check_unique_indexes(doctype.name, field) - # New column with a unique index - # This works because index name is same as fieldname. - new_field = frappe.copy_doc(doctype.fields[0]) - new_field.fieldname = "duplicate_field" - doctype.append("fields", new_field) - doctype.save() - self.check_unique_indexes(doctype.name, new_field.fieldname) - - doctype.delete() - frappe.db.commit() + # New column with a unique index + # This works because index name is same as fieldname. + new_field = frappe.copy_doc(doctype.fields[0]) + new_field.fieldname = "duplicate_field" + doctype.append("fields", new_field) + doctype.save() + self.check_unique_indexes(doctype.name, new_field.fieldname) + finally: + doctype.delete() + frappe.db.commit() def test_uuid_varchar_migration(self): doctype = new_doctype().insert() @@ -176,7 +195,6 @@ class TestDBUpdate(IntegrationTestCase): self.assertEqual(frappe.db.get_column_type(referring_doctype.name, link), "uuid") - @run_only_if(db_type_is.MARIADB) def test_varchar_length(self): from frappe.database.schema import add_column diff --git a/frappe/tests/test_email.py b/frappe/tests/test_email.py index f0c1a8dd33..392316055c 100644 --- a/frappe/tests/test_email.py +++ b/frappe/tests/test_email.py @@ -371,7 +371,6 @@ class TestEmailIntegrationTest(IntegrationTestCase): self.assertEqual(sent_mail["subject"], subject) self.assertSetEqual(set(recipients.split(",")), {m["to"][0] for m in sent_mails}) - @run_only_if(db_type_is.MARIADB) @IntegrationTestCase.change_settings("System Settings", store_attached_pdf_document=1) def test_store_attachments(self): """ "attach print" feature just tells email queue which document to attach, this is not diff --git a/frappe/tests/test_query.py b/frappe/tests/test_query.py index 6f205a655e..d99bc68b1d 100644 --- a/frappe/tests/test_query.py +++ b/frappe/tests/test_query.py @@ -67,9 +67,8 @@ class TestQuery(IntegrationTestCase): def setUp(self): setup_for_tests() - @run_only_if(db_type_is.MARIADB) def test_multiple_tables_in_filters(self): - self.assertEqual( + self.assertQueryEqual( frappe.qb.get_query( "DocType", ["*"], @@ -81,7 +80,6 @@ class TestQuery(IntegrationTestCase): "SELECT `tabDocType`.* FROM `tabDocType` LEFT JOIN `tabDocField` ON `tabDocField`.`parent`=`tabDocType`.`name` AND `tabDocField`.`parenttype`='DocType' AND `tabDocField`.`parentfield`='fields' WHERE `tabDocField`.`name` LIKE 'f%' AND `tabDocType`.`parent`='something'", ) - @run_only_if(db_type_is.MARIADB) def test_string_fields(self): self.assertEqual( frappe.qb.get_query("User", fields="name, email", filters={"name": "Administrator"}).get_sql(), @@ -352,88 +350,73 @@ class TestQuery(IntegrationTestCase): .get_sql(), ) - @run_only_if(db_type_is.MARIADB) def test_filters(self): - self.assertEqual( + self.assertQueryEqual( frappe.qb.get_query( "DocType", fields=["name"], filters={"module.app_name": "frappe"}, ).get_sql(), - "SELECT `tabDocType`.`name` FROM `tabDocType` LEFT JOIN `tabModule Def` ON `tabModule Def`.`name`=`tabDocType`.`module` WHERE `tabModule Def`.`app_name`='frappe'".replace( - "`", '"' if frappe.db.db_type == "postgres" else "`" - ), + "SELECT `tabDocType`.`name` FROM `tabDocType` LEFT JOIN `tabModule Def` ON `tabModule Def`.`name`=`tabDocType`.`module` WHERE `tabModule Def`.`app_name`='frappe'", ) - self.assertEqual( + self.assertQueryEqual( frappe.qb.get_query( "DocType", fields=["name"], filters={"module.app_name": ("like", "frap%")}, ).get_sql(), - "SELECT `tabDocType`.`name` FROM `tabDocType` LEFT JOIN `tabModule Def` ON `tabModule Def`.`name`=`tabDocType`.`module` WHERE `tabModule Def`.`app_name` LIKE 'frap%'".replace( - "`", '"' if frappe.db.db_type == "postgres" else "`" - ), + "SELECT `tabDocType`.`name` FROM `tabDocType` LEFT JOIN `tabModule Def` ON `tabModule Def`.`name`=`tabDocType`.`module` WHERE `tabModule Def`.`app_name` LIKE 'frap%'", ) - self.assertEqual( + self.assertQueryEqual( frappe.qb.get_query( "DocType", fields=["name"], filters={"permissions.role": "System Manager"}, ).get_sql(), - "SELECT `tabDocType`.`name` FROM `tabDocType` LEFT JOIN `tabDocPerm` ON `tabDocPerm`.`parent`=`tabDocType`.`name` AND `tabDocPerm`.`parenttype`='DocType' AND `tabDocPerm`.`parentfield`='permissions' WHERE `tabDocPerm`.`role`='System Manager'".replace( - "`", '"' if frappe.db.db_type == "postgres" else "`" - ), + "SELECT `tabDocType`.`name` FROM `tabDocType` LEFT JOIN `tabDocPerm` ON `tabDocPerm`.`parent`=`tabDocType`.`name` AND `tabDocPerm`.`parenttype`='DocType' AND `tabDocPerm`.`parentfield`='permissions' WHERE `tabDocPerm`.`role`='System Manager'", ) - self.assertEqual( + self.assertQueryEqual( frappe.qb.get_query( "DocType", fields=["module"], filters="", ).get_sql(), - "SELECT `module` FROM `tabDocType` WHERE `name`=''".replace( - "`", '"' if frappe.db.db_type == "postgres" else "`" - ), + "SELECT `module` FROM `tabDocType` WHERE `name`=''", ) - self.assertEqual( + self.assertQueryEqual( frappe.qb.get_query( "DocType", filters=["ToDo", "Note"], ).get_sql(), - "SELECT `name` FROM `tabDocType` WHERE `name` IN ('ToDo','Note')".replace( - "`", '"' if frappe.db.db_type == "postgres" else "`" - ), + "SELECT `name` FROM `tabDocType` WHERE `name` IN ('ToDo','Note')", ) - self.assertEqual( + self.assertQueryEqual( frappe.qb.get_query( "DocType", filters={"name": ("in", [])}, ).get_sql(), - "SELECT `name` FROM `tabDocType` WHERE `name` IN ('')".replace( - "`", '"' if frappe.db.db_type == "postgres" else "`" - ), + "SELECT `name` FROM `tabDocType` WHERE `name` IN ('')", ) - self.assertEqual( + self.assertQueryEqual( frappe.qb.get_query( "DocType", filters=[1, 2, 3], ).get_sql(), - "SELECT `name` FROM `tabDocType` WHERE `name` IN (1,2,3)".replace( - "`", '"' if frappe.db.db_type == "postgres" else "`" - ), + "SELECT `name` FROM `tabDocType` WHERE `name` IN (1,2,3)", ) - self.assertEqual( + self.assertQueryEqual( frappe.qb.get_query( "DocType", filters=[], ).get_sql(), - "SELECT `name` FROM `tabDocType`".replace("`", '"' if frappe.db.db_type == "postgres" else "`"), + "SELECT `name` FROM `tabDocType`", ) def test_nested_filters(self): @@ -1191,7 +1174,7 @@ class TestQuery(IntegrationTestCase): query = frappe.qb.get_query( "DocType", fields=["module.app_name", "name"], - group_by="module.app_name", + group_by="module.app_name, name", ) result = query.run(as_dict=True) self.assertTrue(len(result) > 0) @@ -1210,7 +1193,7 @@ class TestQuery(IntegrationTestCase): "Note", fields=["seen_by.user", "name"], filters={"name": note.name}, - group_by="seen_by.user", + group_by="seen_by.user, name", ) result = query.run(as_dict=True) self.assertTrue(len(result) >= 1) @@ -1268,7 +1251,7 @@ class TestQuery(IntegrationTestCase): query = frappe.qb.get_query( "DocType", fields=["module", "module.app_name", "name"], - group_by="module, module.app_name", + group_by="module, module.app_name, name", order_by="module.app_name", ) result = query.run(as_dict=True) @@ -1513,7 +1496,7 @@ class TestQuery(IntegrationTestCase): # Test simple function without alias query = frappe.qb.get_query("User", fields=["user_type", {"COUNT": "name"}], group_by="user_type") sql = query.get_sql() - self.assertIn("COUNT(`name`)", sql) + self.assertIn(self.normalize_sql("COUNT(`name`)"), sql) self.assertIn("GROUP BY", sql) # Test function with alias @@ -1521,52 +1504,54 @@ class TestQuery(IntegrationTestCase): "User", fields=[{"COUNT": "name", "as": "total_users"}], group_by="user_type" ) sql = query.get_sql() - self.assertIn("COUNT(`name`) `total_users`", sql) + self.assertIn(self.normalize_sql("COUNT(`name`) `total_users`"), sql) # Test SUM function with alias query = frappe.qb.get_query( "User", fields=[{"SUM": "enabled", "as": "total_enabled"}], group_by="user_type" ) sql = query.get_sql() - self.assertIn("SUM(`enabled`) `total_enabled`", sql) + self.assertIn(self.normalize_sql("SUM(`enabled`) `total_enabled`"), sql) # Test MAX function query = frappe.qb.get_query( "User", fields=[{"MAX": "creation", "as": "latest_user"}], group_by="user_type" ) sql = query.get_sql() - self.assertIn("MAX(`creation`) `latest_user`", sql) + self.assertIn(self.normalize_sql("MAX(`creation`) `latest_user`"), sql) # Test MIN function query = frappe.qb.get_query( "User", fields=[{"MIN": "creation", "as": "earliest_user"}], group_by="user_type" ) sql = query.get_sql() - self.assertIn("MIN(`creation`) `earliest_user`", sql) + self.assertIn(self.normalize_sql("MIN(`creation`) `earliest_user`"), sql) # Test AVG function query = frappe.qb.get_query( "User", fields=[{"AVG": "enabled", "as": "avg_enabled"}], group_by="user_type" ) sql = query.get_sql() - self.assertIn("AVG(`enabled`) `avg_enabled`", sql) + self.assertIn(self.normalize_sql("AVG(`enabled`) `avg_enabled`"), sql) # Test ABS function query = frappe.qb.get_query("User", fields=[{"ABS": "enabled", "as": "abs_enabled"}]) sql = query.get_sql() - self.assertIn("ABS(`enabled`) `abs_enabled`", sql) + self.assertIn(self.normalize_sql("ABS(`enabled`) `abs_enabled`"), sql) # Test IFNULL function with two parameters query = frappe.qb.get_query( "User", fields=[{"IFNULL": ["first_name", "'Unknown'"], "as": "safe_name"}] ) sql = query.get_sql() - self.assertIn("IFNULL(`first_name`,'Unknown') `safe_name`", sql) + self.assertIn( + self.normalize_sql("IFNULL(`first_name`,'Unknown') `safe_name`"), self.normalize_sql(sql) + ) # Test TIMESTAMP function query = frappe.qb.get_query("User", fields=[{"TIMESTAMP": "creation", "as": "ts"}]) sql = query.get_sql() - self.assertIn("TIMESTAMP(`creation`) `ts`", sql) + self.assertIn(self.normalize_sql("TIMESTAMP(`creation`) `ts`"), self.normalize_sql(sql)) # Test mixed regular fields and function fields query = frappe.qb.get_query( @@ -1579,21 +1564,23 @@ class TestQuery(IntegrationTestCase): group_by="user_type", ) sql = query.get_sql() - self.assertIn("`user_type`", sql) - self.assertIn("COUNT(`name`) `total_users`", sql) - self.assertIn("MAX(`creation`) `latest_creation`", sql) + self.assertIn(self.normalize_sql("`user_type`"), sql) + self.assertIn(self.normalize_sql("COUNT(`name`) `total_users`"), sql) + self.assertIn(self.normalize_sql("MAX(`creation`) `latest_creation`"), sql) # Test NOW function with no arguments query = frappe.qb.get_query("User", fields=[{"NOW": None, "as": "current_time"}]) sql = query.get_sql() - self.assertIn("NOW() `current_time`", sql) + self.assertIn(self.normalize_sql("NOW() `current_time`"), sql) # Test CONCAT function (which is supported) query = frappe.qb.get_query( "User", fields=[{"CONCAT": ["first_name", "last_name"], "as": "full_name"}] ) sql = query.get_sql() - self.assertIn("CONCAT(`first_name`,`last_name`) `full_name`", sql) + self.assertIn( + self.normalize_sql("CONCAT(`first_name`,`last_name`) `full_name`"), self.normalize_sql(sql) + ) # Test unsupported function validation with self.assertRaises(frappe.ValidationError) as cm: @@ -1611,7 +1598,7 @@ class TestQuery(IntegrationTestCase): self.assertIn("Unsupported function or invalid field name: DROP", str(cm.exception)) def test_not_equal_condition_on_none(self): - self.assertEqual( + self.assertQueryEqual( frappe.qb.get_query( "DocType", ["*"],