Repository: frappe/frappe

Issue:
Not unique table/alias
## Description of the issue

Activating _Show Title In Link Fields_ may lead to wrong SQL query in list view.

## Context information (for bug reports)

**Output of `bench version`**

App                          | Version  | Branch                   | Commit   |
-------------------|---------|-------------------|---------|
erpnext                   | 14.56.0 | version-14-hotfix | 5e517cf |
frappe                     | 14.47.2 | version-14-hotfix | 4d59a87 |

v15+ seems not to be affected. This issue might be resolved with backporting https://github.com/frappe/frappe/pull/21798 .

## Steps to reproduce the issue

1. Restore a backup from a production ERPNext site
2. Go to the **Item Group** list view and verify that it works
3. Click on Menu -> Customize, enable _Show Title In Link Fields_ and save
4. Go to the **Item Group** list view and do a hard refresh

(Reproduced on two independent sites.)

### Observed result

List view shows an error message and does not load any data.

### Expected result

List view works as usual.

### Stacktrace / full error message

Traceback:

```
Traceback (most recent call last):
  File "apps/frappe/frappe/app.py", line 95, in application
    response = frappe.api.handle()
  File "apps/frappe/frappe/api.py", line 55, in handle
    return frappe.handler.handle()
  File "apps/frappe/frappe/handler.py", line 47, in handle
    data = execute_cmd(cmd)
  File "apps/frappe/frappe/handler.py", line 85, in execute_cmd
    return frappe.call(method, **frappe.form_dict)
  File "apps/frappe/frappe/__init__.py", line 1620, in call
    return fn(*args, **newargs)
  File "apps/frappe/frappe/__init__.py", line 808, in wrapper_fn
    retval = fn(*args, **get_newargs(fn, kwargs))
  File "apps/frappe/frappe/desk/reportview.py", line 29, in get
    data = compress(execute(**args), args=args)
  File "apps/frappe/frappe/desk/reportview.py", line 65, in execute
    return DatabaseQuery(doctype).execute(*args, **kwargs)
  File "apps/frappe/frappe/model/db_query.py", line 198, in execute
    result = self.build_and_run()
  File "apps/frappe/frappe/model/db_query.py", line 238, in build_and_run
    return frappe.db.sql(
  File "apps/frappe/frappe/database/database.py", line 220, in sql
    self._cursor.execute(query, values)
  File "env/lib/python3.10/site-packages/pymysql/cursors.py", line 158, in execute
    result = self._query(query)
  File "env/lib/python3.10/site-packages/pymysql/cursors.py", line 325, in _query
    conn.query(q)
  File "env/lib/python3.10/site-packages/pymysql/connections.py", line 549, in query
    self._affected_rows = self._read_query_result(unbuffered=unbuffered)
  File "env/lib/python3.10/site-packages/pymysql/connections.py", line 779, in _read_query_result
    result.read()
  File "env/lib/python3.10/site-packages/pymysql/connections.py", line 1157, in read
    first_packet = self.connection._read_packet()
  File "env/lib/python3.10/site-packages/pymysql/connections.py", line 729, in _read_packet
    packet.raise_for_error()
  File "env/lib/python3.10/site-packages/pymysql/protocol.py", line 221, in raise_for_error
    err.raise_mysql_exception(self._data)
  File "env/lib/python3.10/site-packages/pymysql/err.py", line 143, in raise_mysql_exception
    raise errorclass(errno, errval)
pymysql.err.OperationalError: (1066, "Not unique table/alias: 'tabItem Group'")
```

Request Data:

```
{
	"type": "POST",
	"args": {
		"doctype": "Item Group",
		"fields": "[\"`tabItem Group`.`name`\",\"`tabItem Group`.`owner`\",\"`tabItem Group`.`creation`\",\"`tabItem Group`.`modified`\",\"`tabItem Group`.`modified_by`\",\"`tabItem Group`.`_user_tags`\",\"`tabItem Group`.`_comments`\",\"`tabItem Group`.`_assign`\",\"`tabItem Group`.`_liked_by`\",\"`tabItem Group`.`docstatus`\",\"`tabItem Group`.`idx`\",\"`tabItem Group`.`item_group_name`\",\"`tabItem Group`.`parent_item_group`\",\"`tabItem Group`.`is_group`\",\"`tabItem Group`.`image`\",\"parent_item_group.item_group_name as parent_item_group_item_group_name\"]",
		"filters": "[]",
		"order_by": "`tabItem Group`.`modified` DESC",
		"start": 0,
		"page_length": 20,
		"view": "List",
		"group_by": "`tabItem Group`.`name`",
		"with_comment_count": true
	},
	"freeze": false,
	"freeze_message": "Loading...",
	"headers": {},
	"error_handlers": {},
	"url": "/api/method/frappe.desk.reportview.get"
}
```

Response Data:
```
{
	"exception": "pymysql.err.OperationalError: (1066, \"Not unique table/alias: 'tabItem Group'\")"
}
```

### Related issues

- https://github.com/frappe/frappe/issues/19008
- https://github.com/frappe/frappe/issues/18498
- https://github.com/frappe/frappe/issues/20967


Discussion:

Resolving patch:
diff --git a/frappe/model/db_query.py b/frappe/model/db_query.py
--- a/frappe/model/db_query.py
+++ b/frappe/model/db_query.py
@@ -6,6 +6,7 @@
 import datetime
 import json
 import re
+from collections import Counter
 
 import frappe
 import frappe.defaults
@@ -60,6 +61,8 @@ def __init__(self, doctype, user=None):
 		self.doctype = doctype
 		self.tables = []
 		self.link_tables = []
+		self.linked_table_aliases = {}
+		self.linked_table_counter = Counter()
 		self.conditions = []
 		self.or_conditions = []
 		self.fields = None
@@ -77,7 +80,7 @@ def doctype_meta(self):
 
 	@property
 	def query_tables(self):
-		return self.tables + [d.table_name for d in self.link_tables]
+		return self.tables + [d.table_alias for d in self.link_tables]
 
 	def execute(
 		self,
@@ -269,7 +272,7 @@ def prepare_args(self):
 
 		# left join link tables
 		for link in self.link_tables:
-			args.tables += f" {self.join} `tab{link.doctype}` on (`tab{link.doctype}`.`name` = {self.tables[0]}.`{link.fieldname}`)"
+			args.tables += f" {self.join} {link.table_name} {link.table_alias} on ({link.table_alias}.`name` = {self.tables[0]}.`{link.fieldname}`)"
 
 		if self.grouped_or_conditions:
 			self.conditions.append(f"({' or '.join(self.grouped_or_conditions)})")
@@ -358,8 +361,10 @@ def parse_args(self):
 					continue
 				linked_doctype = linked_field.options
 				if linked_field.fieldtype == "Link":
-					self.append_link_table(linked_doctype, linked_fieldname)
-				field = f"`tab{linked_doctype}`.`{fieldname}`"
+					linked_table = self.append_link_table(linked_doctype, linked_fieldname)
+					field = f"{linked_table.table_alias}.`{fieldname}`"
+				else:
+					field = f"`tab{linked_doctype}`.`{fieldname}`"
 				if alias:
 					field = f"{field} as {alias}"
 				self.fields[self.fields.index(original_field)] = field
@@ -469,11 +474,19 @@ def extract_tables(self):
 
 				table_name = field.split(".", 1)[0]
 
+				# Check if table_name is a linked_table alias
+				for linked_table in self.link_tables:
+					if linked_table.table_alias == table_name:
+						table_name = linked_table.table_name
+						break
+
 				if table_name.lower().startswith("group_concat("):
 					table_name = table_name[13:]
 				if not table_name[0] == "`":
 					table_name = f"`{table_name}`"
-				if table_name not in self.query_tables:
+				if (
+					table_name not in self.query_tables and table_name not in self.linked_table_aliases.values()
+				):
 					self.append_table(table_name)
 
 	def append_table(self, table_name):
@@ -482,14 +495,21 @@ def append_table(self, table_name):
 		self.check_read_permission(doctype)
 
 	def append_link_table(self, doctype, fieldname):
-		for d in self.link_tables:
-			if d.doctype == doctype and d.fieldname == fieldname:
-				return
+		for linked_table in self.link_tables:
+			if linked_table.doctype == doctype and linked_table.fieldname == fieldname:
+				return linked_table
 
 		self.check_read_permission(doctype)
-		self.link_tables.append(
-			frappe._dict(doctype=doctype, fieldname=fieldname, table_name=f"`tab{doctype}`")
+		self.linked_table_counter.update((doctype,))
+		linked_table = frappe._dict(
+			doctype=doctype,
+			fieldname=fieldname,
+			table_name=f"`tab{doctype}`",
+			table_alias=f"`tab{doctype}_{self.linked_table_counter[doctype]}`",
 		)
+		self.linked_table_aliases[linked_table.table_alias.replace("`", "")] = linked_table.table_name
+		self.link_tables.append(linked_table)
+		return linked_table
 
 	def check_read_permission(self, doctype: str, parent_doctype: str | None = None):
 		if self.flags.ignore_permissions:
@@ -656,7 +676,12 @@ def apply_fieldlevel_read_permissions(self):
 			# handle child / joined table fields
 			elif "." in field:
 				table, column = column.split(".", 1)
-				ch_doctype = table.replace("`", "").replace("tab", "", 1)
+				ch_doctype = table
+
+				if ch_doctype in self.linked_table_aliases:
+					ch_doctype = self.linked_table_aliases[ch_doctype]
+
+				ch_doctype = ch_doctype.replace("`", "").replace("tab", "", 1)
 
 				if wrap_grave_quotes(table) in self.query_tables:
 					permitted_child_table_fields = get_permitted_fields(


Test patch:
diff --git a/frappe/tests/test_db_query.py b/frappe/tests/test_db_query.py
--- a/frappe/tests/test_db_query.py
+++ b/frappe/tests/test_db_query.py
@@ -1075,6 +1075,75 @@ def test_coalesce_with_in_ops(self):
 		self.assertNotIn("ifnull", frappe.get_all("User", {"name": ("in", ["a", None])}, run=0))
 		self.assertNotIn("ifnull", frappe.get_all("User", {"name": ("in", ["a", ""])}, run=0))
 
+	def test_ambiguous_linked_tables(self):
+		from frappe.desk.reportview import get
+
+		if not frappe.db.exists("DocType", "Related Todos"):
+			frappe.get_doc(
+				{
+					"doctype": "DocType",
+					"custom": 1,
+					"module": "Custom",
+					"name": "Related Todos",
+					"naming_rule": "Random",
+					"autoname": "hash",
+					"fields": [
+						{
+							"label": "Todo One",
+							"fieldname": "todo_one",
+							"fieldtype": "Link",
+							"options": "ToDo",
+							"reqd": 1,
+						},
+						{
+							"label": "Todo Two",
+							"fieldname": "todo_two",
+							"fieldtype": "Link",
+							"options": "ToDo",
+							"reqd": 1,
+						},
+					],
+				}
+			).insert()
+		else:
+			frappe.db.delete("Related Todos")
+
+		todo_one = frappe.get_doc(
+			{
+				"doctype": "ToDo",
+				"description": "Todo One",
+			}
+		).insert()
+
+		todo_two = frappe.get_doc(
+			{
+				"doctype": "ToDo",
+				"description": "Todo Two",
+			}
+		).insert()
+
+		frappe.get_doc(
+			{
+				"doctype": "Related Todos",
+				"todo_one": todo_one.name,
+				"todo_two": todo_two.name,
+			}
+		).insert()
+
+		frappe.form_dict.doctype = "Related Todos"
+		frappe.form_dict.fields = [
+			"`tabRelated Todos`.`name`",
+			"`tabRelated Todos`.`todo_one`",
+			"`tabRelated Todos`.`todo_two`",
+			# because ToDo.show_title_as_field_link = 1
+			"todo_one.description as todo_one_description",
+			"todo_two.description as todo_two_description",
+		]
+
+		# Shouldn't raise pymysql.err.OperationalError: (1066, "Not unique table/alias: 'tabToDo'")
+		data = get()
+		self.assertEqual(len(data["values"]), 1)
+
 
 class TestReportView(FrappeTestCase):
 	def setUp(self) -> None: