From f30fbe1bbe1443acc525dc0f77f652b19702bf2e Mon Sep 17 00:00:00 2001
From: Andrey Borodin <amborodin@acm.org>
Date: Mon, 27 Jul 2026 17:10:07 +0500
Subject: [PATCH 2/2] psql: complete column names in FROM-first queries

A query that names its tables before its select list tells us what that
list can be written from, so complete it with the columns of those
tables.  psql cannot do this for a query that starts with SELECT, where
nothing is known about the query yet, and it is the practical argument
for the FROM-first syntax.

Complete the columns after each comma of the select list as well, and in
the WHERE, GROUP BY, HAVING and ORDER BY clauses that follow it.  The
last part is not new for ordinary queries, which already get column
completion in WHERE and ORDER BY, but those rules take the word just
before the clause to be the table name, which does not hold once a
select list sits in between.

Along with that, offer FROM as a command-starting keyword, and complete
"FROM tbl" and a finished select list with the clauses that may come
next, so that a query can be written from beginning to end this way.
---
 src/bin/psql/t/010_tab_completion.pl | 18 ++++++++++
 src/bin/psql/tab-complete.in.c       | 50 +++++++++++++++++++++++++++-
 2 files changed, 67 insertions(+), 1 deletion(-)

diff --git a/src/bin/psql/t/010_tab_completion.pl b/src/bin/psql/t/010_tab_completion.pl
index 64e27ef87a3..96aa5922db1 100644
--- a/src/bin/psql/t/010_tab_completion.pl
+++ b/src/bin/psql/t/010_tab_completion.pl
@@ -186,6 +186,24 @@ check_completion("select * from TAB\t", qr/tab1 /, "automatically fold case");
 
 clear_query();
 
+# check FROM-first queries: naming the table before the select list is what
+# lets us offer the columns of that table
+check_completion("fr\t", qr/from /, "complete fr<tab> to from");
+
+check_completion("tab1 sel\t", qr/tab1 select /,
+	"complete SELECT after the table of a FROM-first query");
+
+check_completion("\t\t", qr/c1 +c2/,
+	"offer the table's columns in a FROM-first select list");
+
+check_completion("c1, c2\t", qr/c1, c2 /,
+	"offer the columns again after a comma");
+
+check_completion("where c\t\t", qr/c1 +c2/,
+	"offer the columns in the WHERE clause of a FROM-first query");
+
+clear_query();
+
 # check case-sensitive keyword replacement
 # note: various versions of readline/libedit handle backspacing
 # differently, so just check that the replacement comes out correctly
diff --git a/src/bin/psql/tab-complete.in.c b/src/bin/psql/tab-complete.in.c
index 1cacc8c3ea2..fbbb1751392 100644
--- a/src/bin/psql/tab-complete.in.c
+++ b/src/bin/psql/tab-complete.in.c
@@ -1260,7 +1260,8 @@ static const char *const sql_commands[] = {
 	"ABORT", "ALTER", "ANALYZE", "BEGIN", "CALL", "CHECKPOINT", "CLOSE", "CLUSTER",
 	"COMMENT", "COMMIT", "COPY", "CREATE", "DEALLOCATE", "DECLARE",
 	"DELETE FROM", "DISCARD", "DO", "DROP", "END", "EXECUTE", "EXPLAIN",
-	"FETCH", "GRANT", "IMPORT FOREIGN SCHEMA", "INSERT INTO", "LISTEN", "LOAD", "LOCK",
+	"FETCH", "FROM", "GRANT", "IMPORT FOREIGN SCHEMA", "INSERT INTO",
+	"LISTEN", "LOAD", "LOCK",
 	"MERGE INTO", "MOVE", "NOTIFY", "PREPARE",
 	"REASSIGN", "REFRESH MATERIALIZED VIEW", "REINDEX", "RELEASE", "REPACK",
 	"RESET", "REVOKE", "ROLLBACK",
@@ -1885,6 +1886,13 @@ psql_completion(const char *text, int start, int end)
 #define prev8_wd  (previous_words[7])
 #define prev9_wd  (previous_words[8])
 
+	/*
+	 * previous_words[] runs backwards, so this is how to get at the second
+	 * word on the line.  As above, only use this where a HeadMatches() or
+	 * similar test has already proven that there are that many words.
+	 */
+#define second_wd (previous_words[previous_words_count - 2])
+
 	/* Match the last N words before point, case-insensitively. */
 #define TailMatches(...) \
 	TailMatchesImpl(false, previous_words_count, previous_words, \
@@ -4608,6 +4616,46 @@ match_previous_words(int pattern_id,
 	else if (TailMatches("FOREIGN", "SERVER"))
 		COMPLETE_WITH_QUERY(Query_for_list_of_servers);
 
+/*
+ * FROM-first SELECT.  Naming the table ahead of the select list is what
+ * makes the select list itself completable: a query that begins with SELECT
+ * doesn't say yet what it selects from.  Columns of the clauses that follow
+ * are already completed for ordinary queries, but by rules that take the
+ * word just before the clause to be the table name, which it is not once a
+ * select list sits in between.
+ */
+	/* Complete "FROM <table>" with SELECT or the clauses that can follow */
+	else if (Matches("FROM", MatchAny) && !ends_with(prev_wd, ','))
+		COMPLETE_WITH("SELECT", "WHERE", "GROUP BY", "HAVING", "WINDOW",
+					  "UNION", "INTERSECT", "EXCEPT", "ORDER BY",
+					  "LIMIT", "OFFSET", "FETCH FIRST", "FOR");
+	/* Complete "FROM <table> SELECT" with the table's columns */
+	else if (Matches("FROM", MatchAny, "SELECT"))
+		COMPLETE_WITH_ATTR_PLUS(prev2_wd, "*", "ALL", "DISTINCT");
+	else if (Matches("FROM", MatchAny, "SELECT", "ALL|DISTINCT"))
+		COMPLETE_WITH_ATTR_PLUS(prev3_wd, "*");
+	/* Likewise for each further item of the select list */
+	else if (HeadMatches("FROM", MatchAny, "SELECT") &&
+			 ends_with(prev_wd, ','))
+		COMPLETE_WITH_ATTR(second_wd);
+	/* The columns are also what WHERE, GROUP BY and ORDER BY are written from */
+	else if (HeadMatches("FROM", MatchAny, "SELECT") &&
+			 TailMatches("WHERE|BY|HAVING"))
+		COMPLETE_WITH_ATTR(second_wd);
+
+	/*
+	 * Complete a select list with the clauses that can follow it.  The last
+	 * word is an item of the list only if the one before it is SELECT or
+	 * ended with a comma; otherwise we are somewhere in a later clause and
+	 * have nothing useful to say.
+	 */
+	else if (Matches("FROM", MatchAny, "SELECT", MatchAnyN) &&
+			 (pg_strcasecmp(prev2_wd, "SELECT") == 0 ||
+			  ends_with(prev2_wd, ',')))
+		COMPLETE_WITH("AS", "WHERE", "GROUP BY", "HAVING", "WINDOW",
+					  "UNION", "INTERSECT", "EXCEPT", "ORDER BY",
+					  "LIMIT", "OFFSET", "FETCH FIRST", "FOR");
+
 /*
  * GRANT and REVOKE are allowed inside CREATE SCHEMA and
  * ALTER DEFAULT PRIVILEGES, so use TailMatches
-- 
2.50.1 (Apple Git-155)

